diff --git a/src/Altinn.App.Api/Controllers/ProcessController.cs b/src/Altinn.App.Api/Controllers/ProcessController.cs index d207a2560..1871c3326 100644 --- a/src/Altinn.App.Api/Controllers/ProcessController.cs +++ b/src/Altinn.App.Api/Controllers/ProcessController.cs @@ -2,6 +2,7 @@ using System.Net; using Altinn.App.Api.Infrastructure.Filters; using Altinn.App.Api.Models; +using Altinn.App.Core.Features; using Altinn.App.Core.Features.Validation; using Altinn.App.Core.Helpers; using Altinn.App.Core.Internal.Instances; @@ -34,7 +35,7 @@ public class ProcessController : ControllerBase private readonly ILogger _logger; private readonly IInstanceClient _instanceClient; private readonly IProcessClient _processClient; - private readonly IValidation _validationService; + private readonly IValidationService _validationService; private readonly IAuthorizationService _authorization; private readonly IProcessEngine _processEngine; private readonly IProcessReader _processReader; @@ -46,7 +47,7 @@ public ProcessController( ILogger logger, IInstanceClient instanceClient, IProcessClient processClient, - IValidation validationService, + IValidationService validationService, IAuthorizationService authorization, IProcessReader processReader, IProcessEngine processEngine) @@ -202,7 +203,7 @@ public async Task>> GetNextElements( } } - private async Task CanTaskBeEnded(Instance instance, string currentElementId) + private async Task CanTaskBeEnded(Instance instance, string currentTaskId) { List validationIssues = new List(); @@ -210,7 +211,7 @@ private async Task CanTaskBeEnded(Instance instance, string currentElement if (instance.Process?.CurrentTask?.Validated == null || !instance.Process.CurrentTask.Validated.CanCompleteTask) { - validationIssues = await _validationService.ValidateAndUpdateProcess(instance, currentElementId); + validationIssues = await _validationService.ValidateInstanceAtTask(instance, currentTaskId); canEndTask = await ProcessHelper.CanEndProcessTask(instance, validationIssues); } diff --git a/src/Altinn.App.Api/Controllers/ValidateController.cs b/src/Altinn.App.Api/Controllers/ValidateController.cs index 3c376af73..4da14bbde 100644 --- a/src/Altinn.App.Api/Controllers/ValidateController.cs +++ b/src/Altinn.App.Api/Controllers/ValidateController.cs @@ -1,5 +1,6 @@ #nullable enable +using Altinn.App.Core.Features; using Altinn.App.Core.Features.Validation; using Altinn.App.Core.Helpers; using Altinn.App.Core.Infrastructure.Clients; @@ -21,14 +22,14 @@ public class ValidateController : ControllerBase { private readonly IInstanceClient _instanceClient; private readonly IAppMetadata _appMetadata; - private readonly IValidation _validationService; + private readonly IValidationService _validationService; /// /// Initialises a new instance of the class /// public ValidateController( IInstanceClient instanceClient, - IValidation validationService, + IValidationService validationService, IAppMetadata appMetadata) { _instanceClient = instanceClient; @@ -66,7 +67,7 @@ public async Task ValidateInstance( try { - List messages = await _validationService.ValidateAndUpdateProcess(instance, taskId); + List messages = await _validationService.ValidateInstanceAtTask(instance, taskId); return Ok(messages); } catch (PlatformHttpException exception) @@ -130,9 +131,11 @@ public async Task ValidateData( throw new ValidationException("Unknown element type."); } - messages.AddRange(await _validationService.ValidateDataElement(instance, dataType, element)); + messages.AddRange(await _validationService.ValidateDataElement(instance, element, dataType)); string taskId = instance.Process.CurrentTask.ElementId; + + // Should this be a BadRequest instead? if (!dataType.TaskId.Equals(taskId, StringComparison.OrdinalIgnoreCase)) { ValidationIssue message = new ValidationIssue diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 7b91c4794..addd6d21b 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -133,7 +133,7 @@ public static void AddAppServices(this IServiceCollection services, IConfigurati { // Services for Altinn App services.TryAddTransient(); - services.TryAddTransient(); + services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); services.TryAddSingleton(); @@ -146,7 +146,6 @@ public static void AddAppServices(this IServiceCollection services, IConfigurati #pragma warning restore CS0618, CS0612 // Type or member is obsolete services.TryAddTransient(); services.TryAddTransient(); - services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); diff --git a/src/Altinn.App.Core/Features/FileAnalyzis/FileAnalysisResult.cs b/src/Altinn.App.Core/Features/FileAnalyzis/FileAnalysisResult.cs index 6bbf62d56..279cd97d3 100644 --- a/src/Altinn.App.Core/Features/FileAnalyzis/FileAnalysisResult.cs +++ b/src/Altinn.App.Core/Features/FileAnalyzis/FileAnalysisResult.cs @@ -37,7 +37,7 @@ public FileAnalysisResult(string analyserId) public string? MimeType { get; set; } /// - /// Key/Value pairs contaning fining from the analysis. + /// Key/Value pairs containing findings from the analysis. /// public IDictionary Metadata { get; private set; } = new Dictionary(); } diff --git a/src/Altinn.App.Core/Features/IFormDataValidator.cs b/src/Altinn.App.Core/Features/IFormDataValidator.cs new file mode 100644 index 000000000..0d7d97e1e --- /dev/null +++ b/src/Altinn.App.Core/Features/IFormDataValidator.cs @@ -0,0 +1,49 @@ +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Altinn.App.Core.Features; + +/// +/// Interface for handling validation of form data. +/// (i.e. dataElements with AppLogic defined +/// +public interface IFormDataValidator +{ + /// + /// The data type this validator is for. Typically either hard coded by implementation or + /// or set by constructor using a and a keyed service. + /// + string DataType { get; } + + /// + /// Extension point if the validator should run for multiple data types. + /// Typical users will just set the property. + /// + /// The ID of the data type that might be validated + bool CanValidateDataType(string dataType) => DataType == dataType; + + /// + /// Used for partial validation to ensure that the validator only runs when relevant fields have changed. + /// + /// List of the json path to all changed fields for incremental validation + bool ShouldRunForIncrementalValidation(List? changedFields = null); + + /// + /// Returns the group id of the validator. This is used to run partial validations on the backend. + /// + /// + /// The default implementation should work for most cases. + /// + public string? Code => $"{this.GetType().FullName}-{DataType}"; + + /// + /// + /// + /// + /// + /// + /// + /// List of validation issues + Task> ValidateFormData(Instance instance, DataElement dataElement, object data, List? changedFields = null); +} \ No newline at end of file diff --git a/src/Altinn.App.Core/Features/IInstanceValidator.cs b/src/Altinn.App.Core/Features/IInstanceValidator.cs index 929e65e49..956d0aaeb 100644 --- a/src/Altinn.App.Core/Features/IInstanceValidator.cs +++ b/src/Altinn.App.Core/Features/IInstanceValidator.cs @@ -1,3 +1,4 @@ +using Altinn.App.Core.Features.Validation; using Altinn.Platform.Storage.Interface.Models; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -6,6 +7,7 @@ namespace Altinn.App.Core.Features; /// /// IInstanceValidator defines the methods that are used to validate data and tasks /// +[Obsolete($"Use {nameof(ITaskValidator)}, {nameof(IDataElementValidator)} or {nameof(IFormDataValidator)} instead")] public interface IInstanceValidator { /// diff --git a/src/Altinn.App.Core/Features/ITaskValidator.cs b/src/Altinn.App.Core/Features/ITaskValidator.cs new file mode 100644 index 000000000..614b67e45 --- /dev/null +++ b/src/Altinn.App.Core/Features/ITaskValidator.cs @@ -0,0 +1,39 @@ +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Altinn.App.Core.Features; + +/// +/// Interface for handling validation of tasks. +/// +public interface ITaskValidator +{ + /// + /// The task id this validator is for. Typically either hard coded by implementation or + /// or set by constructor using a and a keyed service. + /// + /// + /// + /// string TaskId { get; init; } + /// // constructor + /// public MyTaskValidator([ServiceKey] string taskId) + /// { + /// TaskId = taskId; + /// } + /// + /// + string TaskId { get; } + + /// + /// Unique code for the validator. Used to run partial validations on the backend. + /// + public string Code => this.GetType().FullName ?? string.Empty; + + /// + /// Actual validation logic for the task + /// + /// The instance to validate + /// List of validation issues to add to this task validation + Task> ValidateTask(Instance instance); +} \ No newline at end of file diff --git a/src/Altinn.App.Core/Features/Validation/Default/DataAnnotationValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/DataAnnotationValidator.cs new file mode 100644 index 000000000..c3f268b47 --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/Default/DataAnnotationValidator.cs @@ -0,0 +1,71 @@ +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features.Validation.Helpers; +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Altinn.App.Core.Features.Validation.Default; + +/// +/// Runs validation on the data object. +/// +public class DataAnnotationValidator : IFormDataValidator +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IObjectModelValidator _objectModelValidator; + private readonly GeneralSettings _generalSettings; + + /// + /// Constructor + /// + public DataAnnotationValidator([ServiceKey] string dataType, IHttpContextAccessor httpContextAccessor, IObjectModelValidator objectModelValidator, IOptions generalSettings) + { + DataType = dataType; + _httpContextAccessor = httpContextAccessor; + _objectModelValidator = objectModelValidator; + _generalSettings = generalSettings.Value; + } + + /// + /// Dummy implementation to satisfy interface, We use instead + /// + public string DataType { get; } + + /// + /// Run validator for all data types. + /// + public bool CanValidateDataType(string dataType) => true; + + /// + /// Disable incremental validation for this validator. + /// + public bool ShouldRunForIncrementalValidation(List? changedFields = null) => false; + + /// + public Task> ValidateFormData(Instance instance, DataElement dataElement, object data, List? changedFields = null) + { + try + { + var modelState = new ModelStateDictionary(); + var actionContext = new ActionContext( + _httpContextAccessor.HttpContext!, + new Microsoft.AspNetCore.Routing.RouteData(), + new ActionDescriptor(), + modelState); + ValidationStateDictionary validationState = new ValidationStateDictionary(); + _objectModelValidator.Validate(actionContext, validationState, null!, data); + + return Task.FromResult(ModelStateHelpers.ModelStateToIssueList(modelState, instance, dataElement, _generalSettings, data.GetType(), ValidationIssueSources.ModelState)); + } + catch (Exception e) + { + return Task.FromException>(e); + } + } +} \ No newline at end of file diff --git a/src/Altinn.App.Core/Features/Validation/Default/DefaultDataElementValidation.cs b/src/Altinn.App.Core/Features/Validation/Default/DefaultDataElementValidation.cs new file mode 100644 index 000000000..1cb99af02 --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/Default/DefaultDataElementValidation.cs @@ -0,0 +1,96 @@ +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Enums; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Features.Validation.Default; + +/// +/// Default validations that run on all data elements to validate metadata and file scan results. +/// +public class DefaultDataElementValidation : IDataElementValidator +{ + public string DataType { get; } + + /// + /// Runs on all data elements to validate metadata and file scan results. + /// + public bool CanValidateDataType(DataType dataType) => true; + + public Task> ValidateDataElement(Instance instance, DataElement dataElement, DataType dataType) + { + var issues = new List(); + if (dataElement.ContentType == null) + { + issues.Add( new ValidationIssue + { + InstanceId = instance.Id, + Code = ValidationIssueCodes.DataElementCodes.MissingContentType, + DataElementId = dataElement.Id, + Severity = ValidationIssueSeverity.Error, + Description = ValidationIssueCodes.DataElementCodes.MissingContentType + }); + } + else + { + var contentTypeWithoutEncoding = dataElement.ContentType.Split(";")[0]; + + if (dataType.AllowedContentTypes != null && dataType.AllowedContentTypes.Count > 0 && + dataType.AllowedContentTypes.All(ct => + !ct.Equals(contentTypeWithoutEncoding, StringComparison.OrdinalIgnoreCase))) + { + issues.Add( new ValidationIssue + { + InstanceId = instance.Id, + DataElementId = dataElement.Id, + Code = ValidationIssueCodes.DataElementCodes.ContentTypeNotAllowed, + Severity = ValidationIssueSeverity.Error, + Description = ValidationIssueCodes.DataElementCodes.ContentTypeNotAllowed, + Field = dataType.Id + }); + } + } + + if (dataType.MaxSize.HasValue && dataType.MaxSize > 0 && + (long)dataType.MaxSize * 1024 * 1024 < dataElement.Size) + { + issues.Add( new ValidationIssue + { + InstanceId = instance.Id, + DataElementId = dataElement.Id, + Code = ValidationIssueCodes.DataElementCodes.DataElementTooLarge, + Severity = ValidationIssueSeverity.Error, + Description = ValidationIssueCodes.DataElementCodes.DataElementTooLarge, + Field = dataType.Id + }); + } + + if (dataType.EnableFileScan && dataElement.FileScanResult == FileScanResult.Infected) + { + issues.Add( new ValidationIssue + { + InstanceId = instance.Id, + DataElementId = dataElement.Id, + Code = ValidationIssueCodes.DataElementCodes.DataElementFileInfected, + Severity = ValidationIssueSeverity.Error, + Description = ValidationIssueCodes.DataElementCodes.DataElementFileInfected, + Field = dataType.Id + }); + } + + if (dataType.EnableFileScan && dataType.ValidationErrorOnPendingFileScan && + dataElement.FileScanResult == FileScanResult.Pending) + { + issues.Add( new ValidationIssue + { + InstanceId = instance.Id, + DataElementId = dataElement.Id, + Code = ValidationIssueCodes.DataElementCodes.DataElementFileScanPending, + Severity = ValidationIssueSeverity.Error, + Description = ValidationIssueCodes.DataElementCodes.DataElementFileScanPending, + Field = dataType.Id + }); + } + + return Task.FromResult(issues); + } +} \ No newline at end of file diff --git a/src/Altinn.App.Core/Features/Validation/Default/DefaultTaskValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/DefaultTaskValidator.cs new file mode 100644 index 000000000..5916f2b62 --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/Default/DefaultTaskValidator.cs @@ -0,0 +1,63 @@ +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Enums; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Altinn.App.Core.Features.Validation.Default; + +/// +/// Implement the default validation of DataElements based on the metadata in appMetadata +/// +public class DefaultTaskValidator : ITaskValidator +{ + private readonly IAppMetadata _appMetadata; + + public DefaultTaskValidator([ServiceKey] string taskId, IAppMetadata appMetadata) + { + TaskId = taskId; + _appMetadata = appMetadata; + } + + /// + public string TaskId { get; } + + public async Task> ValidateTask(Instance instance) + { + var messages = new List(); + var application = await _appMetadata.GetApplicationMetadata(); + + foreach (var dataType in application.DataTypes.Where(et => et.TaskId == TaskId)) + { + List elements = instance.Data.Where(d => d.DataType == dataType.Id).ToList(); + + if (dataType.MaxCount > 0 && dataType.MaxCount < elements.Count) + { + var message = new ValidationIssue + { + InstanceId = instance.Id, + Code = ValidationIssueCodes.InstanceCodes.TooManyDataElementsOfType, + Severity = ValidationIssueSeverity.Error, + Description = ValidationIssueCodes.InstanceCodes.TooManyDataElementsOfType, + Field = dataType.Id + }; + messages.Add(message); + } + + if (dataType.MinCount > 0 && dataType.MinCount > elements.Count) + { + var message = new ValidationIssue + { + InstanceId = instance.Id, + Code = ValidationIssueCodes.InstanceCodes.TooFewDataElementsOfType, + Severity = ValidationIssueSeverity.Error, + Description = ValidationIssueCodes.InstanceCodes.TooFewDataElementsOfType, + Field = dataType.Id + }; + messages.Add(message); + } + } + + return messages; + } +} \ No newline at end of file diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs new file mode 100644 index 000000000..104a236bb --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -0,0 +1,301 @@ +using System.Text.Json; +using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.DataModel; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Altinn.App.Core.Features.Validation.Default; + +/// +/// Validates form data against expression validations +/// +public class ExpressionValidator : IFormDataValidator +{ + private readonly ILogger _logger; + private readonly IAppResources _appResourceService; + private readonly LayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; + + /// + /// Constructor + /// + public ExpressionValidator([ServiceKey] string dataType, ILogger logger, IAppResources appResourceService, LayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer) + { + DataType = dataType; + _logger = logger; + _appResourceService = appResourceService; + _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; + } + + /// + public string DataType { get; } + /// + /// Expression validations should always run (they're likely quicker to run than to figure out if relevant fields changed) + /// + public bool ShouldRunForIncrementalValidation(List? changedFields = null) => true; + + public async Task> ValidateFormData(Instance instance, DataElement dataElement, object data, List? changedFields = null) + { + var rawValidationConfig = _appResourceService.GetValidationConfiguration(dataElement.DataType); + if (rawValidationConfig == null) + { + // No validation configuration exists for this data type + return new List(); + } + + var validationConfig = JsonDocument.Parse(rawValidationConfig).RootElement; + var evaluatorState = await _layoutEvaluatorStateInitializer.Init(instance, data, dataElement.Id); + return Validate(validationConfig, evaluatorState, _logger); + } + + + + public static List Validate(JsonElement validationConfig, LayoutEvaluatorState evaluatorState, ILogger logger) + { + var validationIssues = new List(); + var expressionValidations = ParseExpressionValidationConfig(validationConfig, logger); + foreach (var validationObject in expressionValidations) + { + var baseField = validationObject.Key; + var resolvedFields = evaluatorState.GetResolvedKeys(baseField); + var validations = validationObject.Value; + foreach (var resolvedField in resolvedFields) + { + var positionalArguments = new[] { resolvedField }; + foreach (var validation in validations) + { + try + { + if (validation.Condition == null) + { + continue; + } + + var isInvalid = ExpressionEvaluator.EvaluateExpression(evaluatorState, validation.Condition, null, positionalArguments); + if (isInvalid is not bool) + { + throw new ArgumentException($"Validation condition for {resolvedField} did not evaluate to a boolean"); + } + if ((bool)isInvalid) + { + var validationIssue = new ValidationIssue + { + Field = resolvedField, + Severity = validation.Severity ?? ValidationIssueSeverity.Error, + CustomTextKey = validation.Message, + Code = validation.Message, + Source = ValidationIssueSources.Expression, + }; + validationIssues.Add(validationIssue); + } + } + catch(Exception e) + { + logger.LogError(e, "Error while evaluating expression validation for {resolvedField}", resolvedField); + throw; + } + } + } + } + + + return validationIssues; + } + + private static RawExpressionValidation? ResolveValidationDefinition(string name, JsonElement definition, Dictionary resolvedDefinitions, ILogger logger) + { + var resolvedDefinition = new RawExpressionValidation(); + var rawDefinition = definition.Deserialize(new JsonSerializerOptions + { + ReadCommentHandling = JsonCommentHandling.Skip, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }); + if (rawDefinition == null) + { + logger.LogError($"Validation definition {name} could not be parsed"); + return null; + } + if (rawDefinition.Ref != null) + { + var reference = resolvedDefinitions.GetValueOrDefault(rawDefinition.Ref); + if (reference == null) + { + logger.LogError($"Could not resolve reference {rawDefinition.Ref} for validation {name}"); + return null; + + } + resolvedDefinition.Message = reference.Message; + resolvedDefinition.Condition = reference.Condition; + resolvedDefinition.Severity = reference.Severity; + } + + if (rawDefinition.Message != null) + { + resolvedDefinition.Message = rawDefinition.Message; + } + + if (rawDefinition.Condition != null) + { + resolvedDefinition.Condition = rawDefinition.Condition; + } + + if (rawDefinition.Severity != null) + { + resolvedDefinition.Severity = rawDefinition.Severity; + } + + if (resolvedDefinition.Message == null) + { + logger.LogError($"Validation {name} is missing message"); + return null; + } + + if (resolvedDefinition.Condition == null) + { + logger.LogError($"Validation {name} is missing condition"); + return null; + } + + return resolvedDefinition; + } + + private static ExpressionValidation? ResolveExpressionValidation(string field, JsonElement definition, Dictionary resolvedDefinitions, ILogger logger) + { + + var rawExpressionValidatıon = new RawExpressionValidation(); + + if (definition.ValueKind == JsonValueKind.String) + { + var stringReference = definition.GetString(); + if (stringReference == null) + { + logger.LogError($"Could not resolve null reference for validation for field {field}"); + return null; + } + var reference = resolvedDefinitions.GetValueOrDefault(stringReference); + if (reference == null) + { + logger.LogError($"Could not resolve reference {stringReference} for validation for field {field}"); + return null; + } + rawExpressionValidatıon.Message = reference.Message; + rawExpressionValidatıon.Condition = reference.Condition; + rawExpressionValidatıon.Severity = reference.Severity; + } + else + { + var expressionDefinition = definition.Deserialize(new JsonSerializerOptions + { + ReadCommentHandling = JsonCommentHandling.Skip, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }); + if (expressionDefinition == null) + { + logger.LogError($"Validation for field {field} could not be parsed"); + return null; + } + + if (expressionDefinition.Ref != null) + { + var reference = resolvedDefinitions.GetValueOrDefault(expressionDefinition.Ref); + if (reference == null) + { + logger.LogError($"Could not resolve reference {expressionDefinition.Ref} for validation for field {field}"); + return null; + + } + rawExpressionValidatıon.Message = reference.Message; + rawExpressionValidatıon.Condition = reference.Condition; + rawExpressionValidatıon.Severity = reference.Severity; + } + + if (expressionDefinition.Message != null) + { + rawExpressionValidatıon.Message = expressionDefinition.Message; + } + + if (expressionDefinition.Condition != null) + { + rawExpressionValidatıon.Condition = expressionDefinition.Condition; + } + + if (expressionDefinition.Severity != null) + { + rawExpressionValidatıon.Severity = expressionDefinition.Severity; + } + } + + if (rawExpressionValidatıon.Message == null) + { + logger.LogError($"Validation for field {field} is missing message"); + return null; + } + + if (rawExpressionValidatıon.Condition == null) + { + logger.LogError($"Validation for field {field} is missing condition"); + return null; + } + + var expressionValidation = new ExpressionValidation + { + Message = rawExpressionValidatıon.Message, + Condition = rawExpressionValidatıon.Condition, + Severity = rawExpressionValidatıon.Severity ?? ValidationIssueSeverity.Error, + }; + + return expressionValidation; + } + + private static Dictionary> ParseExpressionValidationConfig(JsonElement expressionValidationConfig, ILogger logger) + { + var expressionValidationDefinitions = new Dictionary(); + JsonElement definitionsObject; + var hasDefinitions = expressionValidationConfig.TryGetProperty("definitions", out definitionsObject); + if (hasDefinitions) + { + foreach (var definitionObject in definitionsObject.EnumerateObject()) + { + var name = definitionObject.Name; + var definition = definitionObject.Value; + var resolvedDefinition = ResolveValidationDefinition(name, definition, expressionValidationDefinitions, logger); + if (resolvedDefinition == null) + { + logger.LogError($"Validation definition {name} could not be resolved"); + continue; + } + expressionValidationDefinitions[name] = resolvedDefinition; + } + } + var expressionValidations = new Dictionary>(); + JsonElement validationsObject; + var hasValidations = expressionValidationConfig.TryGetProperty("validations", out validationsObject); + if (hasValidations) + { + foreach (var validationArray in validationsObject.EnumerateObject()) + { + var field = validationArray.Name; + var validations = validationArray.Value; + foreach (var validation in validations.EnumerateArray()) + { + if (!expressionValidations.ContainsKey(field)) + { + expressionValidations[field] = new List(); + } + var resolvedExpressionValidation = ResolveExpressionValidation(field, validation, expressionValidationDefinitions, logger); + if (resolvedExpressionValidation == null) + { + logger.LogError($"Validation for field {field} could not be resolved"); + continue; + } + expressionValidations[field].Add(resolvedExpressionValidation); + } + } + } + return expressionValidations; + } + +} \ No newline at end of file diff --git a/src/Altinn.App.Core/Features/Validation/Default/LegacyIValidationFormDataValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/LegacyIValidationFormDataValidator.cs new file mode 100644 index 000000000..aea7d7e78 --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/Default/LegacyIValidationFormDataValidator.cs @@ -0,0 +1,52 @@ +#pragma warning disable CS0618 // Type or member is obsolete +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features.Validation.Helpers; +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Altinn.App.Core.Features.Validation.Default; + +/// +/// +/// +public class LegacyIValidationFormDataValidator : IFormDataValidator +{ + private readonly IInstanceValidator? _instanceValidator; + private readonly GeneralSettings _generalSettings; + + /// + /// constructor + /// + public LegacyIValidationFormDataValidator(IInstanceValidator? instanceValidator, IOptions generalSettings) + { + _instanceValidator = instanceValidator; + _generalSettings = generalSettings.Value; + } + + /// + /// + /// + public string DataType { get; } = "AnyType"; + + /// + /// Always run for incremental validation + /// + public bool ShouldRunForIncrementalValidation(List? changedFields = null) => true; + + + /// + public async Task> ValidateFormData(Instance instance, DataElement dataElement, object data, List? changedFields = null) + { + if (_instanceValidator is null) + { + return new List(); + } + + var modelState = new ModelStateDictionary(); + await _instanceValidator.ValidateData(data, modelState); + return ModelStateHelpers.ModelStateToIssueList(modelState, instance, dataElement, _generalSettings, data.GetType(), ValidationIssueSources.Custom); + } +} \ No newline at end of file diff --git a/src/Altinn.App.Core/Features/Validation/Default/LegacyIValidationTaskValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/LegacyIValidationTaskValidator.cs new file mode 100644 index 000000000..02c18c0ec --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/Default/LegacyIValidationTaskValidator.cs @@ -0,0 +1,47 @@ +#pragma warning disable CS0618 // Type or member is obsolete +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features.Validation.Helpers; +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Altinn.App.Core.Features.Validation.Default; + +/// +/// Ensures that the old extention hook is still supported. +/// +public class LegacyIValidationTaskValidator : ITaskValidator +{ + private readonly IInstanceValidator? _instanceValidator; + private readonly GeneralSettings _generalSettings; + + /// + /// Constructor + /// + public LegacyIValidationTaskValidator([ServiceKey] string taskId, IInstanceValidator? instanceValidator, IOptions generalSettings) + { + TaskId = taskId; + _instanceValidator = instanceValidator; + _generalSettings = generalSettings.Value; + } + + /// + /// The task id this validator is registrered for. + /// + public string TaskId { get; } + + /// + public async Task> ValidateTask(Instance instance) + { + if (_instanceValidator is null) + { + return new List(); + } + + var modelState = new ModelStateDictionary(); + await _instanceValidator.ValidateTask(instance, TaskId, modelState); + return ModelStateHelpers.MapModelStateToIssueList(modelState, instance, _generalSettings); + } +} \ No newline at end of file diff --git a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs new file mode 100644 index 000000000..77bd010b2 --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs @@ -0,0 +1,40 @@ +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Altinn.App.Core.Features.Validation.Default; + +public class RequiredLayoutValidator : IFormDataValidator +{ + private readonly LayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; + private readonly IAppResources _appResourcesService; + private readonly IAppMetadata _appMetadata; + + public RequiredLayoutValidator([ServiceKey] string dataType, LayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, IAppResources appResourcesService, IAppMetadata appMetadata) + { + DataType = dataType; + _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; + _appResourcesService = appResourcesService; + _appMetadata = appMetadata; + } + /// + public string DataType { get; } + + /// + /// Required validator should always run for incremental validation, as they're almost quicker to run than to verify. + /// + public bool ShouldRunForIncrementalValidation(List? changedFields = null) => true; + + /// + /// Validate the form data against the required rules in the layout + /// + public async Task> ValidateFormData(Instance instance, DataElement dataElement, object data, List? changedFields = null) + { + var appMetadata = await _appMetadata.GetApplicationMetadata(); + var layoutSet = _appResourcesService.GetLayoutSetForTask(appMetadata.DataTypes.First(dt=>dt.Id == dataElement.DataType).TaskId); + var evaluationState = await _layoutEvaluatorStateInitializer.Init(instance, data, layoutSet?.Id); + return LayoutEvaluator.RunLayoutValidationsForRequired(evaluationState, dataElement.Id); + } +} \ No newline at end of file diff --git a/src/Altinn.App.Core/Features/Validation/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/ExpressionValidator.cs deleted file mode 100644 index 96329e6ea..000000000 --- a/src/Altinn.App.Core/Features/Validation/ExpressionValidator.cs +++ /dev/null @@ -1,276 +0,0 @@ -using System.Text.Json; -using Altinn.App.Core.Helpers; -using Altinn.App.Core.Internal.App; -using Altinn.App.Core.Internal.Expressions; -using Altinn.App.Core.Models.Validation; -using Microsoft.Extensions.Logging; - - -namespace Altinn.App.Core.Features.Validation -{ - /// - /// Validates form data against expression validations - /// - public static class ExpressionValidator - { - /// - public static IEnumerable Validate(string dataType, IAppResources appResourceService, IDataModelAccessor dataModel, LayoutEvaluatorState evaluatorState, ILogger logger) - { - var rawValidationConfig = appResourceService.GetValidationConfiguration(dataType); - if (rawValidationConfig == null) - { - // No validation configuration exists for this data type - return new List(); - } - - var validationConfig = JsonDocument.Parse(rawValidationConfig).RootElement; - return Validate(validationConfig, dataModel, evaluatorState, logger); - } - - /// - public static IEnumerable Validate(JsonElement validationConfig, IDataModelAccessor dataModel, LayoutEvaluatorState evaluatorState, ILogger logger) - { - var validationIssues = new List(); - var expressionValidations = ParseExpressionValidationConfig(validationConfig, logger); - foreach (var validationObject in expressionValidations) - { - var baseField = validationObject.Key; - var resolvedFields = dataModel.GetResolvedKeys(baseField); - var validations = validationObject.Value; - foreach (var resolvedField in resolvedFields) - { - var positionalArguments = new[] { resolvedField }; - foreach (var validation in validations) - { - try - { - if (validation.Condition == null) - { - continue; - } - - var isInvalid = ExpressionEvaluator.EvaluateExpression(evaluatorState, validation.Condition, null, positionalArguments); - if (isInvalid is not bool) - { - throw new ArgumentException($"Validation condition for {resolvedField} did not evaluate to a boolean"); - } - if ((bool)isInvalid) - { - var validationIssue = new ValidationIssue - { - Field = resolvedField, - Severity = validation.Severity ?? ValidationIssueSeverity.Error, - CustomTextKey = validation.Message, - Code = validation.Message, - Source = ValidationIssueSources.Expression, - }; - validationIssues.Add(validationIssue); - } - } - catch(Exception e) - { - logger.LogError(e, "Error while evaluating expression validation for {resolvedField}", resolvedField); - throw; - } - } - } - } - - - return validationIssues; - } - - private static RawExpressionValidation? ResolveValidationDefinition(string name, JsonElement definition, Dictionary resolvedDefinitions, ILogger logger) - { - var resolvedDefinition = new RawExpressionValidation(); - var rawDefinition = definition.Deserialize(new JsonSerializerOptions - { - ReadCommentHandling = JsonCommentHandling.Skip, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }); - if (rawDefinition == null) - { - logger.LogError($"Validation definition {name} could not be parsed"); - return null; - } - if (rawDefinition.Ref != null) - { - var reference = resolvedDefinitions.GetValueOrDefault(rawDefinition.Ref); - if (reference == null) - { - logger.LogError($"Could not resolve reference {rawDefinition.Ref} for validation {name}"); - return null; - - } - resolvedDefinition.Message = reference.Message; - resolvedDefinition.Condition = reference.Condition; - resolvedDefinition.Severity = reference.Severity; - } - - if (rawDefinition.Message != null) - { - resolvedDefinition.Message = rawDefinition.Message; - } - - if (rawDefinition.Condition != null) - { - resolvedDefinition.Condition = rawDefinition.Condition; - } - - if (rawDefinition.Severity != null) - { - resolvedDefinition.Severity = rawDefinition.Severity; - } - - if (resolvedDefinition.Message == null) - { - logger.LogError($"Validation {name} is missing message"); - return null; - } - - if (resolvedDefinition.Condition == null) - { - logger.LogError($"Validation {name} is missing condition"); - return null; - } - - return resolvedDefinition; - } - - private static ExpressionValidation? ResolveExpressionValidation(string field, JsonElement definition, Dictionary resolvedDefinitions, ILogger logger) - { - - var rawExpressionValidatıon = new RawExpressionValidation(); - - if (definition.ValueKind == JsonValueKind.String) - { - var stringReference = definition.GetString(); - if (stringReference == null) - { - logger.LogError($"Could not resolve null reference for validation for field {field}"); - return null; - } - var reference = resolvedDefinitions.GetValueOrDefault(stringReference); - if (reference == null) - { - logger.LogError($"Could not resolve reference {stringReference} for validation for field {field}"); - return null; - } - rawExpressionValidatıon.Message = reference.Message; - rawExpressionValidatıon.Condition = reference.Condition; - rawExpressionValidatıon.Severity = reference.Severity; - } - else - { - var expressionDefinition = definition.Deserialize(new JsonSerializerOptions - { - ReadCommentHandling = JsonCommentHandling.Skip, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }); - if (expressionDefinition == null) - { - logger.LogError($"Validation for field {field} could not be parsed"); - return null; - } - - if (expressionDefinition.Ref != null) - { - var reference = resolvedDefinitions.GetValueOrDefault(expressionDefinition.Ref); - if (reference == null) - { - logger.LogError($"Could not resolve reference {expressionDefinition.Ref} for validation for field {field}"); - return null; - - } - rawExpressionValidatıon.Message = reference.Message; - rawExpressionValidatıon.Condition = reference.Condition; - rawExpressionValidatıon.Severity = reference.Severity; - } - - if (expressionDefinition.Message != null) - { - rawExpressionValidatıon.Message = expressionDefinition.Message; - } - - if (expressionDefinition.Condition != null) - { - rawExpressionValidatıon.Condition = expressionDefinition.Condition; - } - - if (expressionDefinition.Severity != null) - { - rawExpressionValidatıon.Severity = expressionDefinition.Severity; - } - } - - if (rawExpressionValidatıon.Message == null) - { - logger.LogError($"Validation for field {field} is missing message"); - return null; - } - - if (rawExpressionValidatıon.Condition == null) - { - logger.LogError($"Validation for field {field} is missing condition"); - return null; - } - - var expressionValidation = new ExpressionValidation - { - Message = rawExpressionValidatıon.Message, - Condition = rawExpressionValidatıon.Condition, - Severity = rawExpressionValidatıon.Severity ?? ValidationIssueSeverity.Error, - }; - - return expressionValidation; - } - - private static Dictionary> ParseExpressionValidationConfig(JsonElement expressionValidationConfig, ILogger logger) - { - var expressionValidationDefinitions = new Dictionary(); - JsonElement definitionsObject; - var hasDefinitions = expressionValidationConfig.TryGetProperty("definitions", out definitionsObject); - if (hasDefinitions) - { - foreach (var definitionObject in definitionsObject.EnumerateObject()) - { - var name = definitionObject.Name; - var definition = definitionObject.Value; - var resolvedDefinition = ResolveValidationDefinition(name, definition, expressionValidationDefinitions, logger); - if (resolvedDefinition == null) - { - logger.LogError($"Validation definition {name} could not be resolved"); - continue; - } - expressionValidationDefinitions[name] = resolvedDefinition; - } - } - var expressionValidations = new Dictionary>(); - JsonElement validationsObject; - var hasValidations = expressionValidationConfig.TryGetProperty("validations", out validationsObject); - if (hasValidations) - { - foreach (var validationArray in validationsObject.EnumerateObject()) - { - var field = validationArray.Name; - var validations = validationArray.Value; - foreach (var validation in validations.EnumerateArray()) - { - if (!expressionValidations.ContainsKey(field)) - { - expressionValidations[field] = new List(); - } - var resolvedExpressionValidation = ResolveExpressionValidation(field, validation, expressionValidationDefinitions, logger); - if (resolvedExpressionValidation == null) - { - logger.LogError($"Validation for field {field} could not be resolved"); - continue; - } - expressionValidations[field].Add(resolvedExpressionValidation); - } - } - } - return expressionValidations; - } - } -} diff --git a/src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs b/src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs new file mode 100644 index 000000000..e5243b4bd --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs @@ -0,0 +1,108 @@ +using System.Diagnostics; +using System.Linq.Expressions; +using Altinn.App.Core.Helpers; +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Features.Validation; + +/// +/// Simple wrapper for validation of form data that does the type checking for you. +/// +/// The type of the model this class will validate +public abstract class GenericFormDataValidator : IFormDataValidator +{ + /// + /// Constructor to force the DataType to be set. + /// + /// + protected GenericFormDataValidator(string dataType) + { + DataType = dataType; + } + /// + public string DataType { get; private init; } + + private readonly List _runForPrefixes = new List(); + // ReSharper disable once StaticMemberInGenericType + private static readonly AsyncLocal> ValidationIssues = new(); + + /// + /// Default implementation that respects the runFor prefixes. + /// + public bool ShouldRunForIncrementalValidation(List? changedFields = null) + { + if (changedFields == null) + { + return true; + } + + if (_runForPrefixes.Count == 0) + { + return true; + } + + foreach (var prefix in _runForPrefixes) + { + foreach (var changedField in changedFields) + { + if (IsMatch(changedField, prefix)) + { + return true; + } + } + } + + return false; + } + + private static bool IsMatch(string changedField, string prefix) + { + return changedField.StartsWith(prefix) || prefix.StartsWith(changedField); + } + + /// + /// Easy way to configure to only run for fields that start with the given prefix. + /// + /// A selector that will be translated into a prefix + /// The type of the selected element (only for making the compiler happy) + protected void RunFor(Expression> selector) + { + _runForPrefixes.Add(LinqExpressionHelpers.GetJsonPath(selector)); + } + + protected void CreateValidationIssue(Expression> selector, string textKey, ValidationIssueSeverity severity = ValidationIssueSeverity.Error) + { + Debug.Assert(ValidationIssues.Value is not null); + ValidationIssues.Value.Add( new ValidationIssue + { + Field = LinqExpressionHelpers.GetJsonPath(selector), + CustomTextKey = textKey, + Severity = severity + }); + } + + protected void AddValidationIssue(ValidationIssue issue) + { + Debug.Assert(ValidationIssues.Value is not null); + ValidationIssues.Value.Add(issue); + } + + public async Task> ValidateFormData(Instance instance, DataElement dataElement, object data, List? changedFields = null) + { + if (data is not TModel model) + { + throw new ArgumentException($"Data is not of type {typeof(TModel)}"); + } + + ValidationIssues.Value = new List();; + await ValidateFormData(instance, dataElement, model); + return ValidationIssues.Value; + + } + + /// + /// Implement this method to validate the data. + /// + protected abstract Task ValidateFormData(Instance instance, DataElement dataElement, TModel data, List? changedFields = null); +} \ No newline at end of file diff --git a/src/Altinn.App.Core/Features/Validation/Helpers/ModelStateHelpers.cs b/src/Altinn.App.Core/Features/Validation/Helpers/ModelStateHelpers.cs new file mode 100644 index 000000000..221d82556 --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/Helpers/ModelStateHelpers.cs @@ -0,0 +1,156 @@ +using System.Collections; +using System.Text.Json.Serialization; +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Altinn.App.Core.Features.Validation.Helpers; + +public static class ModelStateHelpers +{ + public static List ModelStateToIssueList(ModelStateDictionary modelState, Instance instance, + DataElement dataElement, GeneralSettings generalSettings, Type objectType, string source) + { + var validationIssues = new List(); + + foreach (var modelKey in modelState.Keys) + { + modelState.TryGetValue(modelKey, out var entry); + + if (entry is { ValidationState: ModelValidationState.Invalid }) + { + foreach (var error in entry.Errors) + { + var severityAndMessage = GetSeverityFromMessage(error.ErrorMessage, generalSettings); + validationIssues.Add(new ValidationIssue + { + InstanceId = instance.Id, + DataElementId = dataElement.Id, + Source = source, + Code = severityAndMessage.Message, + Field = ModelKeyToField(modelKey, objectType)!, + Severity = severityAndMessage.Severity, + Description = severityAndMessage.Message + }); + } + } + } + + return validationIssues; + } + + private static (ValidationIssueSeverity Severity, string Message) GetSeverityFromMessage(string originalMessage, + GeneralSettings generalSettings) + { + if (originalMessage.StartsWith(generalSettings.SoftValidationPrefix)) + { + return (ValidationIssueSeverity.Warning, + originalMessage.Remove(0, generalSettings.SoftValidationPrefix.Length)); + } + + if (generalSettings.FixedValidationPrefix != null + && originalMessage.StartsWith(generalSettings.FixedValidationPrefix)) + { + return (ValidationIssueSeverity.Fixed, + originalMessage.Remove(0, generalSettings.FixedValidationPrefix.Length)); + } + + if (originalMessage.StartsWith(generalSettings.InfoValidationPrefix)) + { + return (ValidationIssueSeverity.Informational, + originalMessage.Remove(0, generalSettings.InfoValidationPrefix.Length)); + } + + if (originalMessage.StartsWith(generalSettings.SuccessValidationPrefix)) + { + return (ValidationIssueSeverity.Success, + originalMessage.Remove(0, generalSettings.SuccessValidationPrefix.Length)); + } + + return (ValidationIssueSeverity.Error, originalMessage); + } + + /// + /// Translate the ModelKey from validation to a field that respects [JsonPropertyName] annotations + /// + /// + /// Will be obsolete when updating to net70 or higher and activating + /// https://learn.microsoft.com/en-us/aspnet/core/mvc/models/validation?view=aspnetcore-7.0#use-json-property-names-in-validation-errors + /// + public static string? ModelKeyToField(string? modelKey, Type data) + { + var keyParts = modelKey?.Split('.', 2); + var keyWithIndex = keyParts?.ElementAtOrDefault(0)?.Split('[', 2); + var key = keyWithIndex?.ElementAtOrDefault(0); + var index = keyWithIndex?.ElementAtOrDefault(1); // with traling ']', eg: "3]" + var rest = keyParts?.ElementAtOrDefault(1); + + var property = data?.GetProperties()?.FirstOrDefault(p => p.Name == key); + var jsonPropertyName = property + ?.GetCustomAttributes(true) + .OfType() + .FirstOrDefault() + ?.Name; + if (jsonPropertyName is null) + { + jsonPropertyName = key; + } + + if (index is not null) + { + jsonPropertyName = jsonPropertyName + '[' + index; + } + + if (rest is null) + { + return jsonPropertyName; + } + + var childType = property?.PropertyType; + + // Get the Parameter of IEnumerable properties, if they are not string + if (childType is not null && childType != typeof(string) && childType.IsAssignableTo(typeof(IEnumerable))) + { + childType = childType.GetInterfaces() + .Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + .Select(t => t.GetGenericArguments()[0]).FirstOrDefault(); + } + + if (childType is null) + { + // Give up and return rest, if the child type is not found. + return $"{jsonPropertyName}.{rest}"; + } + + return $"{jsonPropertyName}.{ModelKeyToField(rest, childType)}"; + } + + public static List MapModelStateToIssueList(ModelStateDictionary modelState, Instance instance, + GeneralSettings generalSettings) + { + var validationIssues = new List(); + + foreach (var modelKey in modelState.Keys) + { + modelState.TryGetValue(modelKey, out var entry); + + if (entry != null && entry.ValidationState == ModelValidationState.Invalid) + { + foreach (var error in entry.Errors) + { + var severityAndMessage = GetSeverityFromMessage(error.ErrorMessage, generalSettings); + validationIssues.Add(new ValidationIssue + { + InstanceId = instance.Id, + Code = severityAndMessage.Message, + Severity = severityAndMessage.Severity, + Description = severityAndMessage.Message + }); + } + } + } + + return validationIssues; + } +} \ No newline at end of file diff --git a/src/Altinn.App.Core/Features/Validation/IDataElementValidator.cs b/src/Altinn.App.Core/Features/Validation/IDataElementValidator.cs new file mode 100644 index 000000000..ba4da23df --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/IDataElementValidator.cs @@ -0,0 +1,38 @@ +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Features.Validation; + +/// +/// Validator for data elements. +/// See for an alternative validator for data elements with app logic. +/// and that support incremental validation on save. +/// For validating the content of files, see and +/// +public interface IDataElementValidator +{ + /// + /// The data type that this validator should run for. This is the id of the data type from applicationmetadata.json + /// + /// + /// Used by default in . Overrides might ignore this. + /// + string DataType { get; } + + /// + /// Override this method to customize what data elements this validator should run for. + /// + bool CanValidateDataType(DataType dataType) + { + return DataType == dataType.Id; + } + + /// + /// Run validations for a data element. This is supposed to run quickly + /// + /// The instance to validate + /// + /// + /// + public Task> ValidateDataElement(Instance instance, DataElement dataElement, DataType dataType); +} \ No newline at end of file diff --git a/src/Altinn.App.Core/Features/Validation/IValidation.cs b/src/Altinn.App.Core/Features/Validation/IValidation.cs deleted file mode 100644 index 78c54f791..000000000 --- a/src/Altinn.App.Core/Features/Validation/IValidation.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Altinn.App.Core.Models.Validation; -using Altinn.Platform.Storage.Interface.Models; - -namespace Altinn.App.Core.Features.Validation -{ - /// - /// Describes the public methods of a validation service - /// - public interface IValidation - { - /// - /// Validate an instance for a specified process step. - /// - /// The instance to validate - /// The task to validate the instance for. - /// A list of validation errors if any were found - Task> ValidateAndUpdateProcess(Instance instance, string taskId); - - /// - /// Validate a specific data element. - /// - /// The instance where the data element belong - /// The datatype describing the data element requirements - /// The metadata of a data element to validate - /// A list of validation errors if any were found - Task> ValidateDataElement(Instance instance, DataType dataType, DataElement dataElement); - } -} diff --git a/src/Altinn.App.Core/Features/Validation/IValidationService.cs b/src/Altinn.App.Core/Features/Validation/IValidationService.cs new file mode 100644 index 000000000..1f9ac0739 --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/IValidationService.cs @@ -0,0 +1,53 @@ +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Features.Validation; + +/// +/// Core interface for validation of instances. Only a single implementation of this interface should exist in the app. +/// +public interface IValidationService +{ + /// + /// Validates the instance with all data elements on the current task and ensures that the instance is read for process next. + /// + /// + /// This method executes validations in the following interfaces + /// * for the current task + /// * for all data elements on the current task + /// * for all data elements with app logic on the current task + /// + /// The instance to validate + /// instance.Process?.CurrentTask?.ElementId + /// List of validation issues for this data element + Task> ValidateInstanceAtTask(Instance instance, string taskId); + + /// + /// + /// + /// + /// This method executes validations in the following interfaces + /// * for all data elements on the current task + /// * for all data elements with app logic on the current task + /// + /// This method does not run task validations + /// + /// The instance to validate + /// The data element to run validations for + /// The data type (from applicationmetadata) that the element is an instance of + /// List of validation issues for this data element + Task> ValidateDataElement(Instance instance, DataElement dataElement, DataType dataType); + + /// + /// Validates a single data element. Used by frontend to continuously validate form data as it changes. + /// + /// + /// This method executes validations for + /// + /// The instance to validate + /// The data element to run validations for + /// The type of the data element + /// The data deserialized to the strongly typed object that represents the form data + /// List of json paths for the fields that have changed (used for incremental validation) + Task>> ValidateFormData(Instance instance, DataElement dataElement, DataType dataType, object data, List? changedFields = null); +} \ No newline at end of file diff --git a/src/Altinn.App.Core/Features/Validation/NullInstanceValidator.cs b/src/Altinn.App.Core/Features/Validation/NullInstanceValidator.cs deleted file mode 100644 index 4e3919be4..000000000 --- a/src/Altinn.App.Core/Features/Validation/NullInstanceValidator.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Altinn.Platform.Storage.Interface.Models; -using Microsoft.AspNetCore.Mvc.ModelBinding; - -namespace Altinn.App.Core.Features.Validation; - -/// -/// Default implementation of the IInstanceValidator interface. -/// This implementation does not do any validation and always returns true. -/// -public class NullInstanceValidator: IInstanceValidator -{ - /// - public async Task ValidateData(object data, ModelStateDictionary validationResults) - { - await Task.CompletedTask; - } - - /// - public async Task ValidateTask(Instance instance, string taskId, ModelStateDictionary validationResults) - { - await Task.CompletedTask; - } -} \ No newline at end of file diff --git a/src/Altinn.App.Core/Features/Validation/ValidationAppSI.cs b/src/Altinn.App.Core/Features/Validation/ValidationAppSI.cs deleted file mode 100644 index cfb9028e1..000000000 --- a/src/Altinn.App.Core/Features/Validation/ValidationAppSI.cs +++ /dev/null @@ -1,433 +0,0 @@ -using Altinn.App.Core.Configuration; -using Altinn.App.Core.Helpers.DataModel; -using Altinn.App.Core.Helpers; -using Altinn.App.Core.Internal.App; -using Altinn.App.Core.Internal.AppModel; -using Altinn.App.Core.Internal.Data; -using Altinn.App.Core.Internal.Expressions; -using Altinn.App.Core.Internal.Instances; -using Altinn.App.Core.Models.Validation; -using Altinn.Platform.Storage.Interface.Enums; -using Altinn.Platform.Storage.Interface.Models; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Altinn.App.Core.Features.Validation -{ - /// - /// Represents a validation service for validating instances and their data elements - /// - public class ValidationAppSI : IValidation - { - private readonly ILogger _logger; - private readonly IDataClient _dataClient; - private readonly IInstanceClient _instanceClient; - private readonly IInstanceValidator _instanceValidator; - private readonly IAppModel _appModel; - private readonly IAppResources _appResourcesService; - private readonly IAppMetadata _appMetadata; - private readonly LayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; - private readonly IObjectModelValidator _objectModelValidator; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly GeneralSettings _generalSettings; - private readonly AppSettings _appSettings; - - /// - /// Initializes a new instance of the class. - /// - public ValidationAppSI( - ILogger logger, - IDataClient dataClient, - IInstanceClient instanceClient, - IInstanceValidator instanceValidator, - IAppModel appModel, - IAppResources appResourcesService, - IAppMetadata appMetadata, - IObjectModelValidator objectModelValidator, - LayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, - IHttpContextAccessor httpContextAccessor, - IOptions generalSettings, - IOptions appSettings) - { - _logger = logger; - _dataClient = dataClient; - _instanceClient = instanceClient; - _instanceValidator = instanceValidator; - _appModel = appModel; - _appResourcesService = appResourcesService; - _appMetadata = appMetadata; - _objectModelValidator = objectModelValidator; - _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; - _httpContextAccessor = httpContextAccessor; - _generalSettings = generalSettings.Value; - _appSettings = appSettings.Value; - } - - /// - /// Validate an instance for a specified process step. - /// - /// The instance to validate - /// The task to validate the instance for. - /// A list of validation errors if any were found - public async Task> ValidateAndUpdateProcess(Instance instance, string taskId) - { - _logger.LogInformation("Validation of {instance.Id}", instance.Id); - - List messages = new List(); - - ModelStateDictionary validationResults = new ModelStateDictionary(); - await _instanceValidator.ValidateTask(instance, taskId, validationResults); - messages.AddRange(MapModelStateToIssueList(validationResults, instance)); - - Application application = await _appMetadata.GetApplicationMetadata(); - - foreach (DataType dataType in application.DataTypes.Where(et => et.TaskId == taskId)) - { - List elements = instance.Data.Where(d => d.DataType == dataType.Id).ToList(); - - if (dataType.MaxCount > 0 && dataType.MaxCount < elements.Count) - { - ValidationIssue message = new ValidationIssue - { - InstanceId = instance.Id, - Code = ValidationIssueCodes.InstanceCodes.TooManyDataElementsOfType, - Severity = ValidationIssueSeverity.Error, - Description = ValidationIssueCodes.InstanceCodes.TooManyDataElementsOfType, - Field = dataType.Id - }; - messages.Add(message); - } - - if (dataType.MinCount > 0 && dataType.MinCount > elements.Count) - { - ValidationIssue message = new ValidationIssue - { - InstanceId = instance.Id, - Code = ValidationIssueCodes.InstanceCodes.TooFewDataElementsOfType, - Severity = ValidationIssueSeverity.Error, - Description = ValidationIssueCodes.InstanceCodes.TooFewDataElementsOfType, - Field = dataType.Id - }; - messages.Add(message); - } - - foreach (DataElement dataElement in elements) - { - messages.AddRange(await ValidateDataElement(instance, dataType, dataElement)); - } - } - - instance.Process.CurrentTask.Validated = new ValidationStatus - { - // The condition for completion is met if there are no errors (or other weirdnesses). - CanCompleteTask = messages.Count == 0 || - messages.All(m => m.Severity != ValidationIssueSeverity.Error && m.Severity != ValidationIssueSeverity.Unspecified), - Timestamp = DateTime.Now - }; - - await _instanceClient.UpdateProcess(instance); - return messages; - } - - /// - /// Validate a specific data element. - /// - /// The instance where the data element belong - /// The datatype describing the data element requirements - /// The metadata of a data element to validate - /// A list of validation errors if any were found - public async Task> ValidateDataElement(Instance instance, DataType dataType, DataElement dataElement) - { - _logger.LogInformation("Validation of data element {dataElement.Id} of instance {instance.Id}", dataElement.Id, instance.Id); - - List messages = new List(); - - if (dataElement.ContentType == null) - { - ValidationIssue message = new ValidationIssue - { - InstanceId = instance.Id, - Code = ValidationIssueCodes.DataElementCodes.MissingContentType, - DataElementId = dataElement.Id, - Severity = ValidationIssueSeverity.Error, - Description = ValidationIssueCodes.DataElementCodes.MissingContentType - }; - messages.Add(message); - } - else - { - string contentTypeWithoutEncoding = dataElement.ContentType.Split(";")[0]; - - if (dataType.AllowedContentTypes != null && dataType.AllowedContentTypes.Count > 0 && dataType.AllowedContentTypes.All(ct => !ct.Equals(contentTypeWithoutEncoding, StringComparison.OrdinalIgnoreCase))) - { - ValidationIssue message = new ValidationIssue - { - InstanceId = instance.Id, - DataElementId = dataElement.Id, - Code = ValidationIssueCodes.DataElementCodes.ContentTypeNotAllowed, - Severity = ValidationIssueSeverity.Error, - Description = ValidationIssueCodes.DataElementCodes.ContentTypeNotAllowed, - Field = dataType.Id - }; - messages.Add(message); - } - } - - if (dataType.MaxSize.HasValue && dataType.MaxSize > 0 && (long)dataType.MaxSize * 1024 * 1024 < dataElement.Size) - { - ValidationIssue message = new ValidationIssue - { - InstanceId = instance.Id, - DataElementId = dataElement.Id, - Code = ValidationIssueCodes.DataElementCodes.DataElementTooLarge, - Severity = ValidationIssueSeverity.Error, - Description = ValidationIssueCodes.DataElementCodes.DataElementTooLarge, - Field = dataType.Id - }; - messages.Add(message); - } - - if (dataType.EnableFileScan && dataElement.FileScanResult == FileScanResult.Infected) - { - ValidationIssue message = new ValidationIssue() - { - InstanceId = instance.Id, - DataElementId = dataElement.Id, - Code = ValidationIssueCodes.DataElementCodes.DataElementFileInfected, - Severity = ValidationIssueSeverity.Error, - Description = ValidationIssueCodes.DataElementCodes.DataElementFileInfected, - Field = dataType.Id - }; - messages.Add(message); - } - - if (dataType.EnableFileScan && dataType.ValidationErrorOnPendingFileScan && dataElement.FileScanResult == FileScanResult.Pending) - { - ValidationIssue message = new ValidationIssue() - { - InstanceId = instance.Id, - DataElementId = dataElement.Id, - Code = ValidationIssueCodes.DataElementCodes.DataElementFileScanPending, - Severity = ValidationIssueSeverity.Error, - Description = ValidationIssueCodes.DataElementCodes.DataElementFileScanPending, - Field = dataType.Id - }; - messages.Add(message); - } - - if (dataType.AppLogic?.ClassRef != null) - { - Type modelType = _appModel.GetModelType(dataType.AppLogic.ClassRef); - Guid instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); - string app = instance.AppId.Split("/")[1]; - int instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId); - object data = await _dataClient.GetFormData( - instanceGuid, modelType, instance.Org, app, instanceOwnerPartyId, Guid.Parse(dataElement.Id)); - - LayoutEvaluatorState? evaluationState = null; - - // Remove hidden data before validation - if (_appSettings.RequiredValidation || _appSettings.ExpressionValidation) - { - - var layoutSet = _appResourcesService.GetLayoutSetForTask(dataType.TaskId); - evaluationState = await _layoutEvaluatorStateInitializer.Init(instance, data, layoutSet?.Id); - LayoutEvaluator.RemoveHiddenData(evaluationState, RowRemovalOption.SetToNull); - } - - // Evaluate expressions in layout and validate that all required data is included and that maxLength - // is respected on groups - if (_appSettings.RequiredValidation) - { - var layoutErrors = LayoutEvaluator.RunLayoutValidationsForRequired(evaluationState!, dataElement.Id); - messages.AddRange(layoutErrors); - } - - // Run expression validations - if (_appSettings.ExpressionValidation) - { - var expressionErrors = ExpressionValidator.Validate(dataType.Id, _appResourcesService, new DataModel(data), evaluationState!, _logger); - messages.AddRange(expressionErrors); - } - - // Run Standard mvc validation using the System.ComponentModel.DataAnnotations - ModelStateDictionary dataModelValidationResults = new ModelStateDictionary(); - var actionContext = new ActionContext( - _httpContextAccessor.HttpContext, - new Microsoft.AspNetCore.Routing.RouteData(), - new ActionDescriptor(), - dataModelValidationResults); - ValidationStateDictionary validationState = new ValidationStateDictionary(); - _objectModelValidator.Validate(actionContext, validationState, null, data); - - if (!dataModelValidationResults.IsValid) - { - messages.AddRange(MapModelStateToIssueList(actionContext.ModelState, ValidationIssueSources.ModelState, instance, dataElement.Id, data.GetType())); - } - - // Call custom validation from the IInstanceValidator - ModelStateDictionary customValidationResults = new ModelStateDictionary(); - await _instanceValidator.ValidateData(data, customValidationResults); - - if (!customValidationResults.IsValid) - { - messages.AddRange(MapModelStateToIssueList(customValidationResults, ValidationIssueSources.Custom, instance, dataElement.Id, data.GetType())); - } - - } - - return messages; - } - - private List MapModelStateToIssueList( - ModelStateDictionary modelState, - string source, - Instance instance, - string dataElementId, - Type modelType) - { - List validationIssues = new List(); - - foreach (string modelKey in modelState.Keys) - { - modelState.TryGetValue(modelKey, out ModelStateEntry? entry); - - if (entry != null && entry.ValidationState == ModelValidationState.Invalid) - { - foreach (ModelError error in entry.Errors) - { - var severityAndMessage = GetSeverityFromMessage(error.ErrorMessage); - validationIssues.Add(new ValidationIssue - { - InstanceId = instance.Id, - DataElementId = dataElementId, - Source = source, - Code = severityAndMessage.Message, - Field = ModelKeyToField(modelKey, modelType)!, - Severity = severityAndMessage.Severity, - Description = severityAndMessage.Message - }); - } - } - } - - return validationIssues; - } - - /// - /// Translate the ModelKey from validation to a field that respects [JsonPropertyName] annotations - /// - /// - /// Will be obsolete when updating to net70 or higher and activating https://learn.microsoft.com/en-us/aspnet/core/mvc/models/validation?view=aspnetcore-7.0#use-json-property-names-in-validation-errors - /// - public static string? ModelKeyToField(string? modelKey, Type data) - { - var keyParts = modelKey?.Split('.', 2); - var keyWithIndex = keyParts?.ElementAtOrDefault(0)?.Split('[', 2); - var key = keyWithIndex?.ElementAtOrDefault(0); - var index = keyWithIndex?.ElementAtOrDefault(1); // with traling ']', eg: "3]" - var rest = keyParts?.ElementAtOrDefault(1); - - var property = data?.GetProperties()?.FirstOrDefault(p => p.Name == key); - var jsonPropertyName = property - ?.GetCustomAttributes(true) - .OfType() - .FirstOrDefault() - ?.Name; - if (jsonPropertyName is null) - { - jsonPropertyName = key; - } - - if (index is not null) - { - jsonPropertyName = jsonPropertyName + '[' + index; - } - - if (rest is null) - { - return jsonPropertyName; - } - - var childType = property?.PropertyType; - - // Get the Parameter of IEnumerable properties, if they are not string - if (childType is not null && childType != typeof(string) && childType.IsAssignableTo(typeof(System.Collections.IEnumerable))) - { - childType = childType.GetInterfaces() - .Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)) - .Select(t => t.GetGenericArguments()[0]).FirstOrDefault(); - } - - if (childType is null) - { - // Give up and return rest, if the child type is not found. - return $"{jsonPropertyName}.{rest}"; - } - - return $"{jsonPropertyName}.{ModelKeyToField(rest, childType)}"; - } - - private List MapModelStateToIssueList(ModelStateDictionary modelState, Instance instance) - { - List validationIssues = new List(); - - foreach (string modelKey in modelState.Keys) - { - modelState.TryGetValue(modelKey, out ModelStateEntry? entry); - - if (entry != null && entry.ValidationState == ModelValidationState.Invalid) - { - foreach (ModelError error in entry.Errors) - { - var severityAndMessage = GetSeverityFromMessage(error.ErrorMessage); - validationIssues.Add(new ValidationIssue - { - InstanceId = instance.Id, - Code = severityAndMessage.Message, - Severity = severityAndMessage.Severity, - Description = severityAndMessage.Message - }); - } - } - } - - return validationIssues; - } - - private (ValidationIssueSeverity Severity, string Message) GetSeverityFromMessage(string originalMessage) - { - if (originalMessage.StartsWith(_generalSettings.SoftValidationPrefix)) - { - return (ValidationIssueSeverity.Warning, - originalMessage.Remove(0, _generalSettings.SoftValidationPrefix.Length)); - } - - if (_generalSettings.FixedValidationPrefix != null - && originalMessage.StartsWith(_generalSettings.FixedValidationPrefix)) - { - return (ValidationIssueSeverity.Fixed, - originalMessage.Remove(0, _generalSettings.FixedValidationPrefix.Length)); - } - - if (originalMessage.StartsWith(_generalSettings.InfoValidationPrefix)) - { - return (ValidationIssueSeverity.Informational, - originalMessage.Remove(0, _generalSettings.InfoValidationPrefix.Length)); - } - - if (originalMessage.StartsWith(_generalSettings.SuccessValidationPrefix)) - { - return (ValidationIssueSeverity.Success, - originalMessage.Remove(0, _generalSettings.SuccessValidationPrefix.Length)); - } - - return (ValidationIssueSeverity.Error, originalMessage); - } - } -} diff --git a/src/Altinn.App.Core/Features/Validation/ValidationService.cs b/src/Altinn.App.Core/Features/Validation/ValidationService.cs new file mode 100644 index 000000000..4280cd386 --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/ValidationService.cs @@ -0,0 +1,161 @@ +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.AppModel; +using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Process.Elements; +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Altinn.App.Core.Features.Validation; + +/// +/// Main validation service that encapsulates all validation logic +/// +public class ValidationService : IValidationService +{ + private readonly IServiceProvider _serviceProvider; + private readonly IDataClient _dataClient; + private readonly IAppModel _appModel; + private readonly IAppMetadata _appMetadata; + private readonly ILogger _logger; + + /// + /// Constructor with DI serivces + /// + public ValidationService(IServiceProvider serviceProvider, IDataClient dataClient, IAppModel appModel, IAppMetadata appMetadata, ILogger logger) + { + _serviceProvider = serviceProvider; + _dataClient = dataClient; + _appModel = appModel; + _appMetadata = appMetadata; + _logger = logger; + } + + /// + public async Task> ValidateInstanceAtTask(Instance instance, string taskId) + { + ArgumentNullException.ThrowIfNull(instance); + ArgumentNullException.ThrowIfNull(taskId); + + var issues = new List(); + + // Run task validations + var taskValidators = _serviceProvider.GetServices() + .Where(tv => tv.TaskId == taskId) + .Concat(_serviceProvider.GetKeyedServices(taskId)) + .ToArray(); + + var taskIssuesTask = Task.WhenAll(taskValidators.Select(tv => + { + try + { + _logger.LogDebug("Start running validator {validatorName} on task {taskId} in instance {instanceId}", tv.GetType().Name, taskId, instance.Id); + return tv.ValidateTask(instance); + } + catch (Exception e) + { + _logger.LogError(e, "Error while running validator {validatorName} on task {taskId} in instance {instanceId}", tv.GetType().Name, taskId, instance.Id); + throw; + } + })); + + // Run validations for single data elements + var application = await _appMetadata.GetApplicationMetadata(); + var dataTypesForTask = application.DataTypes.Where(dt => dt.TaskId == taskId).ToArray(); + var dataElementsToValidate = instance.Data.Where(de => dataTypesForTask.Any(dt => dt.Id == de.DataType)).ToArray(); + var dataIssuesTask = Task.WhenAll(dataElementsToValidate.Select(dataElement=>ValidateDataElement(instance, dataElement, dataTypesForTask.First(dt=>dt.Id == dataElement.DataType) ))); + + return (await Task.WhenAll(taskIssuesTask, dataIssuesTask)).SelectMany(x=>x.SelectMany(y=>y)).ToList(); + } + + + /// + public async Task> ValidateDataElement(Instance instance, DataElement dataElement, DataType dataType) + { + ArgumentNullException.ThrowIfNull(instance); + ArgumentNullException.ThrowIfNull(dataElement); + ArgumentNullException.ThrowIfNull(dataElement.DataType); + + // Get both keyed and non-keyed validators for the data type + var validators = _serviceProvider.GetServices() + .Concat(_serviceProvider.GetKeyedServices(dataElement.DataType)) + .Where(v => v.CanValidateDataType(dataType)); + + var dataElementsIssuesTask = Task.WhenAll(validators.Select(async v => + { + try + { + _logger.LogDebug("Start running validator {validatorName} on {dataType} for data element {dataElementId} in instance {instanceId}", v.GetType().Name, dataElement.DataType, dataElement.Id, instance.Id); + return await v.ValidateDataElement(instance, dataElement, dataType); + } + catch (Exception e) + { + _logger.LogError(e, "Error while running validator {validatorName} on {dataType} for data element {dataElementId} in instance {instanceId}", v.GetType().Name, dataElement.DataType, dataElement.Id, instance.Id); + throw; + } + })); + + // Run extra validation on form data elements with app logic + if(dataType.AppLogic?.ClassRef is not null) + { + Type modelType = _appModel.GetModelType(dataType.AppLogic.ClassRef); + + Guid instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); + string app = instance.AppId.Split("/")[1]; + int instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId); + var data = await _dataClient.GetFormData(instanceGuid, modelType, instance.Org, app, instanceOwnerPartyId, Guid.Parse(dataElement.Id)); // TODO: Add method that accepts instance and dataElement + var formDataIssuesDictionary = await ValidateFormData(instance, dataElement, dataType, data, null); + + return (await dataElementsIssuesTask).SelectMany(x=>x) + .Concat(formDataIssuesDictionary.SelectMany(kv=>kv.Value)) + .ToList(); + }; + + return (await dataElementsIssuesTask).SelectMany(x=>x).ToList(); + } + + /// + public async Task>> ValidateFormData(Instance instance, + DataElement dataElement, DataType dataType, object data, List? changedFields = null) + { + ArgumentNullException.ThrowIfNull(instance); + ArgumentNullException.ThrowIfNull(dataElement); + ArgumentNullException.ThrowIfNull(dataElement.DataType); + ArgumentNullException.ThrowIfNull(data); + + // Locate the relevant data validator services from normal and keyed services + var dataValidators = _serviceProvider.GetServices() + .Where(dv => dv.CanValidateDataType(dataElement.DataType)) + .Concat(_serviceProvider.GetKeyedServices(dataElement.DataType)) + .Where(dv => dv.ShouldRunForIncrementalValidation(changedFields)) + .ToArray(); + + if (dataValidators.Length > 0) + { + // TODO: Remove hidden data before validation + } + + var issuesLists = await Task.WhenAll(dataValidators.Select(async (v) => + { + try + { + _logger.LogDebug("Start running validator {validatorName} on {dataType} for data element {dataElementId} in instance {instanceId}", v.GetType().Name, dataElement.DataType, dataElement.Id, instance.Id); + var issues = await v.ValidateFormData(instance, dataElement, data, changedFields); + if (v.Code is not null) + { + issues.ForEach(i=>i.Code = v.Code);// Ensure that the code is set to the validator code + } + return issues; + } + catch (Exception e) + { + _logger.LogError(e, "Error while running validator {validatorName} on {dataType} for data element {dataElementId} in instance {instanceId}", v.GetType().Name, dataElement.DataType, dataElement.Id, instance.Id); + throw; + } + })); + + return dataValidators.Zip(issuesLists).ToDictionary(kv => kv.First.Code ?? string.Empty, kv => kv.Second); + } + +} \ No newline at end of file diff --git a/src/Altinn.App.Core/Helpers/LinqExpressionHelpers.cs b/src/Altinn.App.Core/Helpers/LinqExpressionHelpers.cs new file mode 100644 index 000000000..406f815f4 --- /dev/null +++ b/src/Altinn.App.Core/Helpers/LinqExpressionHelpers.cs @@ -0,0 +1,83 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Helpers; + +/// +/// Utilities for working with +/// +public static class LinqExpressionHelpers +{ + /// + /// Gets the JSON path from an expression + /// + /// The expression + /// The JSON path + public static string GetJsonPath(Expression> expression) + { + return GetJsonPath_internal(expression); + } + + /// + /// Need a private method to avoid the generic type parameter for recursion + /// + private static string GetJsonPath_internal(Expression expression) + { + ArgumentNullException.ThrowIfNull(expression); + + var path = new List(); + Expression? current = expression; + while (current is not null) + { + switch (current) + { + case MemberExpression memberExpression: + path.Add(GetJsonPropertyName(memberExpression.Member)); + current = memberExpression.Expression; + break; + case LambdaExpression lambdaExpression: + current = lambdaExpression.Body; + break; + case ParameterExpression: + // We have reached the root of the expression + current = null; + break; + + // This is a special case for accessing a list item by index + case MethodCallExpression { Method.Name: "get_Item", Arguments: [ ConstantExpression { Value: Int32 index } ], Object: MemberExpression memberExpression }: + path.Add($"{GetJsonPropertyName(memberExpression.Member)}[{index}]"); + current = memberExpression.Expression; + break; + // This is a special case for accessing a list item by index in a variable + case MethodCallExpression { Method.Name: "get_Item", Arguments: [ MemberExpression { Expression: ConstantExpression constantExpression, Member: FieldInfo fieldInfo }], Object: MemberExpression memberExpression }: + // Evaluate the constant expression to get the index + var evaluatedIndex = fieldInfo.GetValue(constantExpression.Value); + path.Add($"{GetJsonPropertyName(memberExpression.Member)}[{evaluatedIndex}]"); + current = memberExpression.Expression; + break; + // This is a special case for selecting all childern of a list using Select + case MethodCallExpression { Method.Name: "Select" } methodCallExpression: + path.Add(GetJsonPath_internal(methodCallExpression.Arguments[1])); + current = methodCallExpression.Arguments[0]; + break; + default: + throw new ArgumentException($"Invalid expression {expression}. Failed reading {current}"); + } + } + + path.Reverse(); + return string.Join(".", path); + } + + private static string GetJsonPropertyName(MemberInfo memberExpressionMember) + { + var jsonPropertyAttribute = memberExpressionMember.GetCustomAttribute(); + if (jsonPropertyAttribute is not null) + { + return jsonPropertyAttribute.Name; + } + + return memberExpressionMember.Name; + } +} \ No newline at end of file diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs index 6d972cc31..8d191936d 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs @@ -102,7 +102,7 @@ public static void RemoveHiddenData(LayoutEvaluatorState state, RowRemovalOption /// /// Return a list of for the given state and dataElementId /// - public static IEnumerable RunLayoutValidationsForRequired(LayoutEvaluatorState state, string dataElementId) + public static List RunLayoutValidationsForRequired(LayoutEvaluatorState state, string dataElementId) { var validationIssues = new List(); diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index cfc0d0162..7f7c2183d 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -179,6 +179,14 @@ public ComponentContext GetComponentContext(string pageName, string componentId, return _dataModel.GetModelData(key, context?.RowIndices); } + /// + /// Get all of the resoved keys (including all possible indexes) from a data model key + /// + public string[] GetResolvedKeys(string key) + { + return _dataModel.GetResolvedKeys(key); + } + /// /// Set the value of a field to null. /// diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index 389d2517f..db8a88c95 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -27,7 +27,7 @@ public LayoutEvaluatorStateInitializer(IAppResources appResources, IOptions /// Initialize LayoutEvaluatorState with given Instance, data object and layoutSetId /// - public Task Init(Instance instance, object data, string? layoutSetId, string? gatewayAction = null) + public virtual Task Init(Instance instance, object data, string? layoutSetId, string? gatewayAction = null) { var layouts = _appResources.GetLayoutModel(layoutSetId); return Task.FromResult(new LayoutEvaluatorState(new DataModel(data), layouts, _frontEndSettings, instance, gatewayAction)); diff --git a/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs b/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs index c10b85366..2d516a073 100644 --- a/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs +++ b/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -12,49 +13,58 @@ public class ValidationIssue /// The seriousness of the identified issue. /// [JsonProperty(PropertyName = "severity")] - [JsonConverter(typeof(StringEnumConverter))] + [Newtonsoft.Json.JsonConverter(typeof(StringEnumConverter))] + [JsonPropertyName("severity")] + [System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))] public ValidationIssueSeverity Severity { get; set; } /// /// The unique id of the specific element with the identified issue. /// [JsonProperty(PropertyName = "instanceId")] + [JsonPropertyName("instanceId")] public string? InstanceId { get; set; } /// /// The uniqe id of the data element of a given instance with the identified issue. /// [JsonProperty(PropertyName = "dataElementId")] + [JsonPropertyName("dataElementId")] public string? DataElementId { get; set; } /// /// A reference to a property the issue is a bout. /// [JsonProperty(PropertyName = "field")] + [JsonPropertyName("field")] public string? Field { get; set; } /// /// A system readable identification of the type of issue. /// [JsonProperty(PropertyName = "code")] + [JsonPropertyName("code")] public string? Code { get; set; } /// /// A human readable description of the issue. /// [JsonProperty(PropertyName = "description")] + [JsonPropertyName("description")] public string? Description { get; set; } /// /// The validation source of the issue eg. File, Schema, Component /// [JsonProperty(PropertyName = "source")] + [JsonPropertyName("source")] public string? Source { get; set; } /// /// The custom text key to use for the localized text in the frontend. /// [JsonProperty(PropertyName = "customTextKey")] + [JsonPropertyName("customTextKey")] public string? CustomTextKey { get; set; } } } diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs index 09bf34d14..75e56fcfa 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs @@ -1,5 +1,6 @@ using System.Net; using Altinn.App.Api.Controllers; +using Altinn.App.Core.Features; using Altinn.App.Core.Features.Validation; using Altinn.App.Core.Helpers; using Altinn.App.Core.Internal.App; @@ -21,7 +22,7 @@ public async Task ValidateInstance_returns_NotFound_when_GetInstance_returns_nul // Arrange var instanceMock = new Mock(); var appMetadataMock = new Mock(); - var validationMock = new Mock(); + var validationMock = new Mock(); const string org = "ttd"; const string app = "app"; @@ -45,7 +46,7 @@ public async Task ValidateInstance_throws_ValidationException_when_Instance_Proc // Arrange var instanceMock = new Mock(); var appMetadataMock = new Mock(); - var validationMock = new Mock(); + var validationMock = new Mock(); const string org = "ttd"; const string app = "app"; @@ -77,7 +78,7 @@ public async Task ValidateInstance_throws_ValidationException_when_Instance_Proc // Arrange var instanceMock = new Mock(); var appMetadataMock = new Mock(); - var validationMock = new Mock(); + var validationMock = new Mock(); const string org = "ttd"; const string app = "app"; @@ -112,7 +113,7 @@ public async Task ValidateInstance_returns_OK_with_messages() // Arrange var instanceMock = new Mock(); var appMetadataMock = new Mock(); - var validationMock = new Mock(); + var validationMock = new Mock(); const string org = "ttd"; const string app = "app"; @@ -143,7 +144,7 @@ public async Task ValidateInstance_returns_OK_with_messages() instanceMock.Setup(i => i.GetInstance(app, org, instanceOwnerPartyId, instanceId)) .Returns(Task.FromResult(instance)); - validationMock.Setup(v => v.ValidateAndUpdateProcess(instance, "dummy")) + validationMock.Setup(v => v.ValidateInstanceAtTask(instance, "dummy")) .Returns(Task.FromResult(validationResult)); // Act @@ -161,7 +162,7 @@ public async Task ValidateInstance_returns_403_when_not_authorized() // Arrange var instanceMock = new Mock(); var appMetadataMock = new Mock(); - var validationMock = new Mock(); + var validationMock = new Mock(); const string org = "ttd"; const string app = "app"; @@ -186,7 +187,7 @@ public async Task ValidateInstance_returns_403_when_not_authorized() instanceMock.Setup(i => i.GetInstance(app, org, instanceOwnerPartyId, instanceId)) .Returns(Task.FromResult(instance)); - validationMock.Setup(v => v.ValidateAndUpdateProcess(instance, "dummy")) + validationMock.Setup(v => v.ValidateInstanceAtTask(instance, "dummy")) .Throws(exception); // Act @@ -204,7 +205,7 @@ public async Task ValidateInstance_throws_PlatformHttpException_when_not_403() // Arrange var instanceMock = new Mock(); var appMetadataMock = new Mock(); - var validationMock = new Mock(); + var validationMock = new Mock(); const string org = "ttd"; const string app = "app"; @@ -229,7 +230,7 @@ public async Task ValidateInstance_throws_PlatformHttpException_when_not_403() instanceMock.Setup(i => i.GetInstance(app, org, instanceOwnerPartyId, instanceId)) .Returns(Task.FromResult(instance)); - validationMock.Setup(v => v.ValidateAndUpdateProcess(instance, "dummy")) + validationMock.Setup(v => v.ValidateInstanceAtTask(instance, "dummy")) .Throws(exception); // Act diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs index 2e76cc67c..16a50bd7e 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs @@ -1,5 +1,6 @@ using System.Collections; using Altinn.App.Api.Controllers; +using Altinn.App.Core.Features; using Altinn.App.Core.Features.Validation; using Altinn.App.Core.Helpers; using Altinn.App.Core.Infrastructure.Clients; @@ -236,18 +237,18 @@ public async Task TestValidateData(ValidateDataTestScenario testScenario) private static ValidateController SetupController(string app, string org, int instanceOwnerId, ValidateDataTestScenario testScenario) { - (Mock instanceMock, Mock appResourceMock, Mock validationMock) = + (Mock instanceMock, Mock appResourceMock, Mock validationMock) = SetupMocks(app, org, instanceOwnerId, testScenario); return new ValidateController(instanceMock.Object, validationMock.Object, appResourceMock.Object); } - private static (Mock, Mock, Mock) SetupMocks(string app, string org, + private static (Mock, Mock, Mock) SetupMocks(string app, string org, int instanceOwnerId, ValidateDataTestScenario testScenario) { var instanceMock = new Mock(); var appMetadataMock = new Mock(); - var validationMock = new Mock(); + var validationMock = new Mock(); if (testScenario.ReceivedInstance != null) { instanceMock.Setup(i => i.GetInstance(app, org, instanceOwnerId, testScenario.InstanceId)) @@ -263,8 +264,8 @@ private static (Mock, Mock, Mock) Se { validationMock.Setup(v => v.ValidateDataElement( testScenario.ReceivedInstance, - testScenario.ReceivedApplication.DataTypes.First(), - testScenario.ReceivedInstance.Data.First())) + testScenario.ReceivedInstance.Data.First(), + testScenario.ReceivedApplication.DataTypes.First())) .Returns(Task.FromResult>(testScenario.ReceivedValidationIssues)); } diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/DataAnnotationValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/DataAnnotationValidatorTests.cs new file mode 100644 index 000000000..f8f1e0a70 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/DataAnnotationValidatorTests.cs @@ -0,0 +1,217 @@ +#nullable enable + +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Text.Json.Serialization; +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features.Validation.Default; +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace Altinn.App.Core.Tests.Features.Validators.Default; + +public class DataAnnotationValidatorTests : IClassFixture +{ + private readonly DataAnnotationValidator _validator; + + public DataAnnotationValidatorTests(DataAnnotationsTestFixture fixture) + { + _validator = fixture.App.Services.GetRequiredKeyedService(DataAnnotationsTestFixture.DataType); + } + + private class TestClass + { + [Required] + [JsonPropertyName("requiredProperty")] + public string? RequiredProperty { get; set; } + + [StringLength(5)] + [JsonPropertyName("stringLength")] + public string? StringLengthProperty { get; set; } + + [Range(1, 10)] + [JsonPropertyName("range")] + public int RangeProperty { get; set; } + + [RegularExpression("^[0-9]*$")] + [JsonPropertyName("regularExpression")] + public string? RegularExpressionProperty { get; set; } + + [EmailAddress] + public string? EmailAddressProperty { get; set; } + + public TestClass? NestedProperty { get; set; } + } + + [Fact] + public void CanValidateDataType() + { + // Act + var result = _validator.CanValidateDataType(DataAnnotationsTestFixture.DataType); + + // Assert + Assert.True(result); + } + + [Fact] + public void ShouldRunForIncrementalValidation() + { + // Act + var result = _validator.ShouldRunForIncrementalValidation(); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidateFormData() + { + // Arrange + var instance = new Instance(); + var dataElement = new DataElement(); + var data = new object(); + var changedFields = new List(); + + // Prepare + + // Act + var result = await _validator.ValidateFormData(instance, dataElement, data, changedFields); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public async Task Validate_ValidFormData_NoErrors() + { + // Arrange + var instance = new Instance(); + var dataElement = new DataElement(); + var data = new TestClass() + { + RangeProperty = 3, + RequiredProperty = "test", + EmailAddressProperty = "test@altinn.no", + RegularExpressionProperty = "12345", + StringLengthProperty = "12345", + NestedProperty = new TestClass() + { + RangeProperty = 3, + RequiredProperty = "test", + EmailAddressProperty = "test@altinn.no", + RegularExpressionProperty = "12345", + StringLengthProperty = "12345", + } + }; + var changedFields = new List(); + + // Act + var result = await _validator.ValidateFormData(instance, dataElement, data, changedFields); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task ValidateFormData_RequiredProperty() + { + // Arrange + var instance = new Instance(); + var dataElement = new DataElement(); + var data = new TestClass() + { + NestedProperty = new(), + }; + var changedFields = new List(); + + // Act + var result = await _validator.ValidateFormData(instance, dataElement, data, changedFields); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEquivalentTo(JsonSerializer.Deserialize>(""" + [ + { + "severity": "Error", + "instanceId": null, + "dataElementId": null, + "field": "range", + "code": "The field RangeProperty must be between 1 and 10.", + "description": "The field RangeProperty must be between 1 and 10.", + "source": "ModelState", + "customTextKey": null + }, + { + "severity": "Error", + "instanceId": null, + "dataElementId": null, + "field": "requiredProperty", + "code": "The RequiredProperty field is required.", + "description": "The RequiredProperty field is required.", + "source": "ModelState", + "customTextKey": null + }, + { + "severity": "Error", + "instanceId": null, + "dataElementId": null, + "field": "NestedProperty.range", + "code": "The field RangeProperty must be between 1 and 10.", + "description": "The field RangeProperty must be between 1 and 10.", + "source": "ModelState", + "customTextKey": null + }, + { + "severity": "Error", + "instanceId": null, + "dataElementId": null, + "field": "NestedProperty.requiredProperty", + "code": "The RequiredProperty field is required.", + "description": "The RequiredProperty field is required.", + "source": "ModelState", + "customTextKey": null + } + ] + """)); + } +} + +/// +/// System.ComponentModel.DataAnnotations does not provide an easy way to run validations recursively in a unit test, +/// so we need to instantiate a WebApplication to get the IObjectModelValidator. +/// +/// A full WebApplicationFactory seemed a little overkill, so we just use a WebApplicationBuilder. +/// +public class DataAnnotationsTestFixture : IAsyncDisposable +{ + public const string DataType = "test"; + + private readonly DefaultHttpContext _httpContext = new DefaultHttpContext(); + + private readonly Mock _httpContextAccessor = new Mock(); + + public WebApplication App { get; } + + public DataAnnotationsTestFixture() + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddMvc(); + builder.Services.AddKeyedTransient(DataType); + _httpContextAccessor.Setup(a => a.HttpContext).Returns(_httpContext); + builder.Services.AddSingleton(_httpContextAccessor.Object); + builder.Services.Configure(builder.Configuration.GetSection("GeneralSettings")); + App = builder.Build(); + } + + public ValueTask DisposeAsync() + { + return App.DisposeAsync(); + } +} \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs new file mode 100644 index 000000000..23a48df5a --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs @@ -0,0 +1,124 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features.Validation; +using Altinn.App.Core.Features.Validation.Default; +using Altinn.App.Core.Helpers; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Layout; +using Altinn.App.Core.Models.Validation; +using Altinn.App.Core.Tests.Helpers; +using Altinn.App.Core.Tests.LayoutExpressions; +using Altinn.Platform.Storage.Interface.Models; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; +using Xunit.Sdk; + +namespace Altinn.App.Core.Tests.Features.Validators.Default; + +public class ExpressionValidatorTests +{ + private const string DataType = "default"; + private readonly ExpressionValidator _validator; + private readonly Mock> _logger = new(); + private readonly Mock _appResources = new(MockBehavior.Strict); + private readonly IOptions _frontendSettings = Options.Create(new FrontEndSettings()); + private readonly Mock _layoutInitializer; + + public ExpressionValidatorTests() + { + _layoutInitializer = new(MockBehavior.Strict, _appResources.Object, _frontendSettings) { CallBase = false }; + _validator = + new ExpressionValidator(DataType, _logger.Object, _appResources.Object, _layoutInitializer.Object); + } + + [Theory] + [ExpressionTest] + public async Task RunExpressionValidationTest(ExpressionValidationTestModel testCase) + { + var instance = new Instance(); + var dataElement = new DataElement(); + + var dataModel = new JsonDataModel(testCase.FormData); + + var evaluatorState = new LayoutEvaluatorState(dataModel, testCase.Layouts, _frontendSettings.Value, instance); + _layoutInitializer + .Setup(init => init.Init(It.Is(i => i == instance), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(evaluatorState); + _appResources + .Setup(ar => ar.GetValidationConfiguration(null)) + .Returns(JsonSerializer.Serialize(testCase.ValidationConfig)); + + LayoutEvaluator.RemoveHiddenData(evaluatorState, RowRemovalOption.SetToNull); + var validationIssues = await _validator.ValidateFormData(instance, dataElement, null!); + + var result = validationIssues.Select(i => new + { + Message = i.CustomTextKey, + Severity = i.Severity, + Field = i.Field, + }); + + var expected = testCase.Expects.Select(e => new + { + Message = e.Message, + Severity = e.Severity, + Field = e.Field, + }); + + result.Should().BeEquivalentTo(expected); + } +} + +public class ExpressionTestAttribute : DataAttribute +{ + public override IEnumerable GetData(MethodInfo methodInfo) + { + var files = Directory.GetFiles(Path.Join("Features", "Validators", "shared-expression-validation-tests")); + + foreach (var file in files) + { + var data = File.ReadAllText(file); + ExpressionValidationTestModel testCase = JsonSerializer.Deserialize( + data, + new JsonSerializerOptions + { + ReadCommentHandling = JsonCommentHandling.Skip, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + })!; + yield return new object[] { testCase }; + } + } +} + +public class ExpressionValidationTestModel +{ + public string Name { get; set; } + + public ExpectedObject[] Expects { get; set; } + + public JsonElement ValidationConfig { get; set; } + + public JsonObject FormData { get; set; } + + [JsonConverter(typeof(LayoutModelConverterFromObject))] + public LayoutModel Layouts { get; set; } + + public class ExpectedObject + { + public string Message { get; set; } + + [JsonConverter(typeof(FrontendSeverityConverter))] + public ValidationIssueSeverity Severity { get; set; } + + public string Field { get; set; } + + public string ComponentId { get; set; } + } +} diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs new file mode 100644 index 000000000..402e92015 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs @@ -0,0 +1,141 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features; +using Altinn.App.Core.Features.Validation.Default; +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Altinn.App.Core.Tests.Features.Validators.Default +{ + public class LegacyIValidationFormDataTests + { + private readonly LegacyIValidationFormDataValidator _validator; + private readonly Mock _instanceValidator = new(); + + public LegacyIValidationFormDataTests() + { + var generalSettings = new GeneralSettings(); + _validator = + new LegacyIValidationFormDataValidator(_instanceValidator.Object, Options.Create(generalSettings)); + } + + [Fact] + public async Task ValidateFormData_NoErrors() + { + // Arrange + var data = new object(); + var changedFields = new List(); + + var validator = new LegacyIValidationFormDataValidator(null, Options.Create(new GeneralSettings())); + validator.ShouldRunForIncrementalValidation(changedFields).Should().BeTrue(); + + // Act + var result = await validator.ValidateFormData(new Instance(), new DataElement(), data, changedFields); + + // Assert + Assert.Empty(result); + } + + [Fact] + public async Task ValidateFormData_WithErrors() + { + // Arrange + var data = new object(); + var changedFields = new List(); + + _instanceValidator + .Setup(iv => iv.ValidateData(It.IsAny(), It.IsAny())) + .Callback((object _, ModelStateDictionary modelState) => + { + modelState.AddModelError("test", "test"); + modelState.AddModelError("ddd", "*FIXED*test"); + }); + + // Act + var result = await _validator.ValidateFormData(new Instance(), new DataElement(), data, changedFields); + + // Assert + result.Should().BeEquivalentTo( + JsonSerializer.Deserialize>(""" + [ + { + "severity": "Fixed", + "instanceId": null, + "dataElementId": null, + "field": "ddd", + "code": "test", + "description": "test", + "source": "Custom", + "customTextKey": null + }, + { + "severity": "Error", + "instanceId": null, + "dataElementId": null, + "field": "test", + "code": "test", + "description": "test", + "source": "Custom", + "customTextKey": null + } + ] + """)); + } + + private class TestModel + { + [JsonPropertyName("test")] + public string Test { get; set; } + + public int IntegerWithout { get; set; } + + [JsonPropertyName("child")] + public TestModel Child { get; set; } + + [JsonPropertyName("children")] + public List TestList { get; set; } + } + + [Theory] + [InlineData("test", "test", "test with small case")] + [InlineData("Test", "test", "test with capital case gets rewritten")] + [InlineData("NotModelMatch", "NotModelMatch", "Error that does not mach model is kept as is")] + [InlineData("Child.TestList[2].child", "child.children[2].child", "TestList is renamed to children because of JsonPropertyName")] + [InlineData("test.children.child", "test.children.child", "valid JsonPropertyName based path is kept as is")] + public async Task ValidateErrorAndMappingWithCustomModel(string errorKey, string field, string errorMessage) + { + // Arrange + var data = new TestModel(); + var changedFields = new List(); + + _instanceValidator + .Setup(iv => iv.ValidateData(It.IsAny(), It.IsAny())) + .Callback((object _, ModelStateDictionary modelState) => + { + modelState.AddModelError(errorKey, errorMessage); + modelState.AddModelError(errorKey, "*FIXED*" + errorMessage + " Fixed"); + }); + + // Act + var result = await _validator.ValidateFormData(new Instance(), new DataElement(), data, changedFields); + + // Assert + result.Should().HaveCount(2); + var errorIssue = result.Should().ContainSingle(i => i.Severity == ValidationIssueSeverity.Error).Which; + errorIssue.Field.Should().Be(field); + errorIssue.Severity.Should().Be(ValidationIssueSeverity.Error); + errorIssue.Description.Should().Be(errorMessage); + + var fixedIssue = result.Should().ContainSingle(i => i.Severity == ValidationIssueSeverity.Fixed).Which; + fixedIssue.Field.Should().Be(field); + fixedIssue.Severity.Should().Be(ValidationIssueSeverity.Fixed); + fixedIssue.Description.Should().Be(errorMessage + " Fixed"); + } + } +} \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ExpressionValidationTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ExpressionValidationTests.cs index 19d23481d..c54a28792 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ExpressionValidationTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ExpressionValidationTests.cs @@ -3,6 +3,7 @@ using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Altinn.App.Core.Features.Validation; +using Altinn.App.Core.Features.Validation.Default; using Altinn.App.Core.Helpers; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models.Layout; @@ -23,12 +24,12 @@ public class ExpressionValidationTests [ExpressionTest] public void RunExpressionValidationTest(ExpressionValidationTestModel testCase) { - var logger = Mock.Of>(); + var logger = Mock.Of>(); var dataModel = new JsonDataModel(testCase.FormData); var evaluatorState = new LayoutEvaluatorState(dataModel, testCase.Layouts, new(), new()); LayoutEvaluator.RemoveHiddenData(evaluatorState, RowRemovalOption.SetToNull); - var validationIssues = ExpressionValidator.Validate(testCase.ValidationConfig, dataModel, evaluatorState, logger).ToArray(); + var validationIssues = ExpressionValidator.Validate(testCase.ValidationConfig, evaluatorState, logger).ToArray(); var result = validationIssues.Select(i => new { diff --git a/test/Altinn.App.Core.Tests/Features/Validators/GenericValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/GenericValidatorTests.cs new file mode 100644 index 000000000..96f234de2 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Validators/GenericValidatorTests.cs @@ -0,0 +1,83 @@ +#nullable enable +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Text.Json.Serialization; +using Altinn.App.Core.Features.Validation; +using Altinn.Platform.Storage.Interface.Models; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Altinn.App.Core.Tests.Features.Validators; + +public class GenericValidatorTests +{ + private class MyModel + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("age")] + public int? Age { get; set; } + + [JsonPropertyName("children")] + public List? Children { get; set; } + } + + private class TestValidator : GenericFormDataValidator + { + public TestValidator() : base("MyType") + { + } + + // Custom method to make the protected RunFor possible to call from the test + public void RunForExternal(Expression> selector) + { + RunFor(selector); + } + + protected override Task ValidateFormData(Instance instance, DataElement dataElement, MyModel data, List? changedFields = null) + { + throw new NotImplementedException(); + } + } + + [Fact] + public void TestShouldRun() + { + var testValidator = new TestValidator(); + testValidator.RunForExternal(m => m.Name); + testValidator.ShouldRunForIncrementalValidation().Should().BeTrue(); + testValidator.ShouldRunForIncrementalValidation(new List() { "name" }).Should().BeTrue(); + testValidator.ShouldRunForIncrementalValidation(new List() { "age" }).Should().BeFalse(); + } + + [Theory] + [InlineData("name", false)] + [InlineData("age", false)] + [InlineData("children", true)] + [InlineData("children[0]", true)] + [InlineData("children[0].age", false)] + [InlineData("children[2]", false)] + public void TestShouldRunWithIndexedRow(string changedField, bool shouldBe) + { + var testValidator = new TestValidator(); + testValidator.RunForExternal(m => m.Children![0].Name); + testValidator.ShouldRunForIncrementalValidation(new List() { changedField }).Should().Be(shouldBe); + } + + [Theory] + [InlineData("name", false)] + [InlineData("age", false)] + [InlineData("children", true)] + + // [InlineData("children[0]", true)] //TODO:Fix + [InlineData("children[0].age", false)] + [InlineData("children[2]", false)] + public void TestShouldRunWithSelectAllRow(string changedField, bool shouldBe) + { + var testValidator = new TestValidator(); + testValidator.RunForExternal(m => m.Children!.Select(c => c.Name)); + testValidator.ShouldRunForIncrementalValidation(new List() { changedField }).Should().Be(shouldBe); + } +} \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationAppSITests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs similarity index 51% rename from test/Altinn.App.Core.Tests/Features/Validators/ValidationAppSITests.cs rename to test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs index 83b38a9dc..4dae865de 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationAppSITests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs @@ -2,17 +2,21 @@ using System.Text.Json.Serialization; using Altinn.App.Core.Features; using Altinn.App.Core.Features.Validation; +using Altinn.App.Core.Features.Validation.Default; +using Altinn.App.Core.Features.Validation.Helpers; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Internal.Instances; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Enums; using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; @@ -20,21 +24,54 @@ namespace Altinn.App.Core.Tests.Features.Validators; -public class ValidationAppSITests +public class ValidationServiceOldTests { + private readonly Mock> _loggerMock = new(); + private readonly Mock _dataClientMock = new(); + private readonly Mock _appModelMock = new(); + private readonly Mock _appMetadataMock = new(); + private readonly ServiceCollection _serviceCollection = new(); + + private ApplicationMetadata _applicationMetadata = new("tdd/test") + { + DataTypes = new List() + { + new DataType() + { + Id = "test", + TaskId = "Task_1", + EnableFileScan = false, + ValidationErrorOnPendingFileScan = false, + } + } + }; + + public ValidationServiceOldTests() + { + _serviceCollection.AddSingleton(_loggerMock.Object); + _serviceCollection.AddSingleton(_dataClientMock.Object); + _serviceCollection.AddSingleton(); + _serviceCollection.AddSingleton(_appModelMock.Object); + _serviceCollection.AddSingleton(_appMetadataMock.Object); + _serviceCollection.AddSingleton(); + _appMetadataMock.Setup(am => am.GetApplicationMetadata()).ReturnsAsync(_applicationMetadata); + } + [Fact] public async Task FileScanEnabled_VirusFound_ValidationShouldFail() { - ValidationAppSI validationAppSI = ConfigureMockServicesForValidation(); + await using var serviceProvider = _serviceCollection.BuildServiceProvider(); + IValidationService validationService = serviceProvider.GetRequiredService(); var instance = new Instance(); var dataType = new DataType() { EnableFileScan = true }; var dataElement = new DataElement() { + DataType = "test", FileScanResult = FileScanResult.Infected }; - List validationIssues = await validationAppSI.ValidateDataElement(instance, dataType, dataElement); + List validationIssues = await validationService.ValidateDataElement(instance, dataElement, dataType); validationIssues.FirstOrDefault(vi => vi.Code == "DataElementFileInfected").Should().NotBeNull(); } @@ -42,16 +79,21 @@ public async Task FileScanEnabled_VirusFound_ValidationShouldFail() [Fact] public async Task FileScanEnabled_PendingScanNotEnabled_ValidationShouldNotFail() { - ValidationAppSI validationAppSI = ConfigureMockServicesForValidation(); + await using var serviceProvider = _serviceCollection.BuildServiceProvider(); + IValidationService validationService = serviceProvider.GetRequiredService(); - var instance = new Instance(); - var dataType = new DataType() { EnableFileScan = true }; + var dataType = new DataType() + { Id = "test", TaskId = "Task_1", AppLogic = null, EnableFileScan = true }; + var instance = new Instance() + { + }; var dataElement = new DataElement() { - FileScanResult = FileScanResult.Pending + DataType = "test", + FileScanResult = FileScanResult.Pending, }; - List validationIssues = await validationAppSI.ValidateDataElement(instance, dataType, dataElement); + List validationIssues = await validationService.ValidateDataElement(instance, dataElement, dataType); validationIssues.FirstOrDefault(vi => vi.Code == "DataElementFileScanPending").Should().BeNull(); } @@ -59,16 +101,18 @@ public async Task FileScanEnabled_PendingScanNotEnabled_ValidationShouldNotFail( [Fact] public async Task FileScanEnabled_PendingScanEnabled_ValidationShouldNotFail() { - ValidationAppSI validationAppSI = ConfigureMockServicesForValidation(); + await using var serviceProvider = _serviceCollection.BuildServiceProvider(); + IValidationService validationService = serviceProvider.GetRequiredService(); var instance = new Instance(); var dataType = new DataType() { EnableFileScan = true, ValidationErrorOnPendingFileScan = true }; var dataElement = new DataElement() { + DataType = "test", FileScanResult = FileScanResult.Pending }; - List validationIssues = await validationAppSI.ValidateDataElement(instance, dataType, dataElement); + List validationIssues = await validationService.ValidateDataElement(instance, dataElement, dataType); validationIssues.FirstOrDefault(vi => vi.Code == "DataElementFileScanPending").Should().NotBeNull(); } @@ -76,153 +120,124 @@ public async Task FileScanEnabled_PendingScanEnabled_ValidationShouldNotFail() [Fact] public async Task FileScanEnabled_Clean_ValidationShouldNotFail() { - ValidationAppSI validationAppSI = ConfigureMockServicesForValidation(); + await using var serviceProvider = _serviceCollection.BuildServiceProvider(); + IValidationService validationService = serviceProvider.GetRequiredService(); var instance = new Instance(); var dataType = new DataType() { EnableFileScan = true, ValidationErrorOnPendingFileScan = true }; var dataElement = new DataElement() { - FileScanResult = FileScanResult.Clean + DataType = "test", + FileScanResult = FileScanResult.Clean, }; - List validationIssues = await validationAppSI.ValidateDataElement(instance, dataType, dataElement); + List validationIssues = await validationService.ValidateDataElement(instance, dataElement, dataType); validationIssues.FirstOrDefault(vi => vi.Code == "DataElementFileInfected").Should().BeNull(); validationIssues.FirstOrDefault(vi => vi.Code == "DataElementFileScanPending").Should().BeNull(); } - private static ValidationAppSI ConfigureMockServicesForValidation() - { - Mock> loggerMock = new(); - var dataMock = new Mock(); - var instanceMock = new Mock(); - var instanceValidator = new Mock(); - var appModelMock = new Mock(); - var appResourcesMock = new Mock(); - var appMetadataMock = new Mock(); - var objectModelValidatorMock = new Mock(); - var layoutEvaluatorStateInitializer = new LayoutEvaluatorStateInitializer(appResourcesMock.Object, Microsoft.Extensions.Options.Options.Create(new Configuration.FrontEndSettings())); - var httpContextAccessorMock = new Mock(); - var generalSettings = Microsoft.Extensions.Options.Options.Create(new Configuration.GeneralSettings()); - var appSettings = Microsoft.Extensions.Options.Options.Create(new Configuration.AppSettings()); - - var validationAppSI = new ValidationAppSI( - loggerMock.Object, - dataMock.Object, - instanceMock.Object, - instanceValidator.Object, - appModelMock.Object, - appResourcesMock.Object, - appMetadataMock.Object, - objectModelValidatorMock.Object, - layoutEvaluatorStateInitializer, - httpContextAccessorMock.Object, - generalSettings, - appSettings); - return validationAppSI; - } - [Fact] public void ModelKeyToField_NullInputWithoutType_ReturnsNull() { - ValidationAppSI.ModelKeyToField(null, null!).Should().BeNull(); + ModelStateHelpers.ModelKeyToField(null, null!).Should().BeNull(); } [Fact] public void ModelKeyToField_StringInputWithoutType_ReturnsSameString() { - ValidationAppSI.ModelKeyToField("null", null!).Should().Be("null"); + ModelStateHelpers.ModelKeyToField("null", null!).Should().Be("null"); } [Fact] public void ModelKeyToField_NullInput_ReturnsNull() { - ValidationAppSI.ModelKeyToField(null, typeof(TestModel)).Should().BeNull(); + ModelStateHelpers.ModelKeyToField(null, typeof(TestModel)).Should().BeNull(); } [Fact] public void ModelKeyToField_StringInput_ReturnsSameString() { - ValidationAppSI.ModelKeyToField("null", typeof(TestModel)).Should().Be("null"); + ModelStateHelpers.ModelKeyToField("null", typeof(TestModel)).Should().Be("null"); } [Fact] public void ModelKeyToField_StringInputWithAttr_ReturnsMappedString() { - ValidationAppSI.ModelKeyToField("FirstLevelProp", typeof(TestModel)).Should().Be("level1"); + ModelStateHelpers.ModelKeyToField("FirstLevelProp", typeof(TestModel)).Should().Be("level1"); } [Fact] public void ModelKeyToField_SubModel_ReturnsMappedString() { - ValidationAppSI.ModelKeyToField("SubTestModel.DecimalNumber", typeof(TestModel)).Should().Be("sub.decimal"); + ModelStateHelpers.ModelKeyToField("SubTestModel.DecimalNumber", typeof(TestModel)).Should().Be("sub.decimal"); } [Fact] public void ModelKeyToField_SubModelNullable_ReturnsMappedString() { - ValidationAppSI.ModelKeyToField("SubTestModel.StringNullable", typeof(TestModel)).Should().Be("sub.nullableString"); + ModelStateHelpers.ModelKeyToField("SubTestModel.StringNullable", typeof(TestModel)).Should().Be("sub.nullableString"); } [Fact] public void ModelKeyToField_SubModelWithSubmodel_ReturnsMappedString() { - ValidationAppSI.ModelKeyToField("SubTestModel.StringNullable", typeof(TestModel)).Should().Be("sub.nullableString"); + ModelStateHelpers.ModelKeyToField("SubTestModel.StringNullable", typeof(TestModel)).Should().Be("sub.nullableString"); } [Fact] public void ModelKeyToField_SubModelNull_ReturnsMappedString() { - ValidationAppSI.ModelKeyToField("SubTestModelNull.DecimalNumber", typeof(TestModel)).Should().Be("subnull.decimal"); + ModelStateHelpers.ModelKeyToField("SubTestModelNull.DecimalNumber", typeof(TestModel)).Should().Be("subnull.decimal"); } [Fact] public void ModelKeyToField_SubModelNullNullable_ReturnsMappedString() { - ValidationAppSI.ModelKeyToField("SubTestModelNull.StringNullable", typeof(TestModel)).Should().Be("subnull.nullableString"); + ModelStateHelpers.ModelKeyToField("SubTestModelNull.StringNullable", typeof(TestModel)).Should().Be("subnull.nullableString"); } [Fact] public void ModelKeyToField_SubModelNullWithSubmodel_ReturnsMappedString() { - ValidationAppSI.ModelKeyToField("SubTestModelNull.StringNullable", typeof(TestModel)).Should().Be("subnull.nullableString"); + ModelStateHelpers.ModelKeyToField("SubTestModelNull.StringNullable", typeof(TestModel)).Should().Be("subnull.nullableString"); } // Test lists [Fact] public void ModelKeyToField_List_IgnoresMissingIndex() { - ValidationAppSI.ModelKeyToField("SubTestModelList.StringNullable", typeof(TestModel)).Should().Be("subList.nullableString"); + ModelStateHelpers.ModelKeyToField("SubTestModelList.StringNullable", typeof(TestModel)).Should().Be("subList.nullableString"); } [Fact] public void ModelKeyToField_List_ProxiesIndex() { - ValidationAppSI.ModelKeyToField("SubTestModelList[123].StringNullable", typeof(TestModel)).Should().Be("subList[123].nullableString"); + ModelStateHelpers.ModelKeyToField("SubTestModelList[123].StringNullable", typeof(TestModel)).Should().Be("subList[123].nullableString"); } [Fact] public void ModelKeyToField_ListOfList_ProxiesIndex() { - ValidationAppSI.ModelKeyToField("SubTestModelList[123].ListOfDecimal[5]", typeof(TestModel)).Should().Be("subList[123].decimalList[5]"); + ModelStateHelpers.ModelKeyToField("SubTestModelList[123].ListOfDecimal[5]", typeof(TestModel)).Should().Be("subList[123].decimalList[5]"); } [Fact] public void ModelKeyToField_ListOfList_IgnoresMissing() { - ValidationAppSI.ModelKeyToField("SubTestModelList[123].ListOfDecimal", typeof(TestModel)).Should().Be("subList[123].decimalList"); + ModelStateHelpers.ModelKeyToField("SubTestModelList[123].ListOfDecimal", typeof(TestModel)).Should().Be("subList[123].decimalList"); } [Fact] public void ModelKeyToField_ListOfListNullable_IgnoresMissing() { - ValidationAppSI.ModelKeyToField("SubTestModelList[123].ListOfNullableDecimal", typeof(TestModel)).Should().Be("subList[123].nullableDecimalList"); + ModelStateHelpers.ModelKeyToField("SubTestModelList[123].ListOfNullableDecimal", typeof(TestModel)).Should().Be("subList[123].nullableDecimalList"); } [Fact] public void ModelKeyToField_ListOfListOfListNullable_IgnoresMissingButPropagatesOthers() { - ValidationAppSI.ModelKeyToField("SubTestModelList[123].SubTestModelList.ListOfNullableDecimal[123456]", typeof(TestModel)).Should().Be("subList[123].subList.nullableDecimalList[123456]"); + ModelStateHelpers.ModelKeyToField("SubTestModelList[123].SubTestModelList.ListOfNullableDecimal[123456]", typeof(TestModel)).Should().Be("subList[123].subList.nullableDecimalList[123456]"); } public class TestModel @@ -257,4 +272,4 @@ public class SubTestModel [JsonPropertyName("subList")] public List SubTestModelList { get; set; } = default!; } -} \ No newline at end of file +} diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs new file mode 100644 index 000000000..37afdd1b2 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs @@ -0,0 +1,115 @@ +#nullable enable +using System.Text.Json.Serialization; +using Altinn.App.Core.Features; +using Altinn.App.Core.Features.Validation; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.AppModel; +using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Altinn.App.Core.Tests.Features.Validators; + +public class ValidationServiceTests +{ + private class MyModel + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("age")] + public int? Age { get; set; } + } + + private static readonly DataElement DefaultDataElement = new() + { + DataType = "MyType", + }; + + private readonly Mock> _loggerMock = new(); + private readonly Mock _dataClientMock = new(); + private readonly Mock _appModelMock = new(); + private readonly Mock _appMetadataMock = new(); + private readonly ServiceCollection _serviceCollection = new(); + + public ValidationServiceTests() + { + _serviceCollection.AddSingleton(_loggerMock.Object); + _serviceCollection.AddSingleton(_dataClientMock.Object); + _serviceCollection.AddSingleton(); + _serviceCollection.AddSingleton(_appModelMock.Object); + _serviceCollection.AddSingleton(_appMetadataMock.Object); + } + + private class MyNameValidator : GenericFormDataValidator + { + public MyNameValidator() : base(DefaultDataElement.DataType) + { + RunFor(m => m.Name); + } + + protected override async Task ValidateFormData(Instance instance, DataElement dataElement, MyModel data, List? changedFields = null) + { + if (data.Name != "Ola") + { + CreateValidationIssue(m => m.Name, "NameNotOla"); + } + } + } + + [Fact] + public async Task ValidateFormData_WithNoValidators_ReturnsNoErrors() + { + await using var serviceProvider = _serviceCollection.BuildServiceProvider(); + + var validatorService = serviceProvider.GetRequiredService(); + var data = new MyModel { Name = "Ola" }; + var result = await validatorService.ValidateFormData(new Instance(), DefaultDataElement, null!, data); + result.Should().BeEmpty(); + } + + [Fact] + public async Task ValidateFormData_WithMyNameValidator_ReturnsNoErrorsWhenNameIsOla() + { + _serviceCollection.AddSingleton(); + await using var serviceProvider = _serviceCollection.BuildServiceProvider(); + + var validatorService = serviceProvider.GetRequiredService(); + var data = new MyModel { Name = "Ola" }; + var result = await validatorService.ValidateFormData(new Instance(), DefaultDataElement, null!, data); + result.Should().ContainKey("Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+MyNameValidator-MyType").WhoseValue.Should().HaveCount(0); + result.Should().HaveCount(1); + } + + [Fact] + public async Task ValidateFormData_WithMyNameValidator_ReturnsErrorsWhenNameIsKari() + { + _serviceCollection.AddSingleton(); + await using var serviceProvider = _serviceCollection.BuildServiceProvider(); + + var validatorService = serviceProvider.GetRequiredService(); + var data = new MyModel { Name = "Kari" }; + var result = await validatorService.ValidateFormData(new Instance(), DefaultDataElement, null!, data); + result.Should().ContainKey("Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+MyNameValidator-MyType").WhoseValue.Should().ContainSingle().Which.CustomTextKey.Should().Be("NameNotOla"); + result.Should().HaveCount(1); + } + + [Fact] + public async Task ValidateFormData_WithMyNameValidator_ReturnsNoErrorsWhenOnlyAgeIsSoupposedlyChanged() + { + _serviceCollection.AddSingleton(); + await using var serviceProvider = _serviceCollection.BuildServiceProvider(); + + var validatorService = serviceProvider.GetRequiredService(); + var data = new MyModel { Name = "Kari" }; + var result = await validatorService.ValidateFormData(new Instance(), DefaultDataElement, null!, data, new List { "age" }); + result.Should() + .NotContainKey("Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+MyNameValidator"); + result.Should().HaveCount(0); + } +} \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Helpers/LinqExpressionHelpersTests.cs b/test/Altinn.App.Core.Tests/Helpers/LinqExpressionHelpersTests.cs new file mode 100644 index 000000000..cd2152ff2 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Helpers/LinqExpressionHelpersTests.cs @@ -0,0 +1,68 @@ +#nullable enable +using System.Text.Json.Serialization; +using Altinn.App.Core.Helpers; +using FluentAssertions; +using Xunit; + +namespace Altinn.App.Core.Tests.Helpers; + +public class LinqExpressionHelpersTests +{ + public class MyModel + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("age")] + public int? Age { get; set; } + + public List? Children { get; set; } + } + + [Fact] + public void GetJsonPath_OneLevelDeep() + { + var propertyName = LinqExpressionHelpers.GetJsonPath(m => m.Name); + propertyName.Should().Be("name"); + } + + [Fact] + public void GetJsonPath_TwoLevelsDeep() + { + var propertyName = LinqExpressionHelpers.GetJsonPath(m => m.Children![0].Age); + propertyName.Should().Be("Children[0].age"); + } + + [Fact()] + public void GetJsonPath_TwoLevelsDeepUsingFirst() + { + var propertyName = LinqExpressionHelpers.GetJsonPath>(m => m.Children!.Select(c => c.Age)); + propertyName.Should().Be("Children.age"); + } + + [Fact] + public void GetJsonPath_ManyLevelsDeep() + { + var propertyName = LinqExpressionHelpers.GetJsonPath>(m => m.Children![0].Children![2].Children!.Select(c => c.Children![44].Age)); + propertyName.Should().Be("Children[0].Children[2].Children.Children[44].age"); + } + + [Fact] + public void GetJsonPath_IndexInVariable() + { + var index = 123; + var propertyName = LinqExpressionHelpers.GetJsonPath(m => m.Children![index].Age); + propertyName.Should().Be("Children[123].age"); + } + + [Fact] + public void GetJsonPath_IndexInVariableLoop() + { + for (var i = 0; i < 10; i++) + { + var index = i; // Needed to avoid "Access to modified closure" error + var propertyName = LinqExpressionHelpers.GetJsonPath(m => m.Children![index].Age); + propertyName.Should().Be($"Children[{index}].age"); + } + } +} \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Implementation/NullInstanceValidatorTests.cs b/test/Altinn.App.Core.Tests/Implementation/NullInstanceValidatorTests.cs deleted file mode 100644 index a421a8281..000000000 --- a/test/Altinn.App.Core.Tests/Implementation/NullInstanceValidatorTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Altinn.App.Core.Features.Validation; -using Altinn.App.PlatformServices.Tests.Implementation.TestResources; -using Altinn.Platform.Storage.Interface.Models; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using Xunit; - -namespace Altinn.App.PlatformServices.Tests.Implementation; - -public class NullInstanceValidatorTests -{ - [Fact] - public async void NullInstanceValidator_ValidateData_does_not_add_to_ValidationResults() - { - // Arrange - var instanceValidator = new NullInstanceValidator(); - ModelStateDictionary validationResults = new ModelStateDictionary(); - - // Act - await instanceValidator.ValidateData(new DummyModel(), validationResults); - - // Assert - Assert.Empty(validationResults); - } - - [Fact] - public async void NullInstanceValidator_ValidateTask_does_not_add_to_ValidationResults() - { - // Arrange - var instanceValidator = new NullInstanceValidator(); - ModelStateDictionary validationResults = new ModelStateDictionary(); - - // Act - await instanceValidator.ValidateTask(new Instance(), "task0", validationResults); - - // Assert - Assert.Empty(validationResults); - } -} \ No newline at end of file