diff --git a/src/Microsoft.AspNet.Mvc.Core/Formatters/JsonInputFormatter.cs b/src/Microsoft.AspNet.Mvc.Core/Formatters/JsonInputFormatter.cs index 6cdff8e15d..f5802887fd 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Formatters/JsonInputFormatter.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Formatters/JsonInputFormatter.cs @@ -148,7 +148,7 @@ private Task ReadInternal(InputFormatterContext context, errorHandler = (sender, e) => { var exception = e.ErrorContext.Error; - context.ActionContext.ModelState.AddModelError(e.ErrorContext.Path, e.ErrorContext.Error); + context.ActionContext.ModelState.TryAddModelError(e.ErrorContext.Path, e.ErrorContext.Error); // Error must always be marked as handled // Failure to do so can cause the exception to be rethrown at every recursive level and // overflow the stack for x64 CLR processes diff --git a/src/Microsoft.AspNet.Mvc.Core/MvcOptions.cs b/src/Microsoft.AspNet.Mvc.Core/MvcOptions.cs index 895b0941f5..e47e149eca 100644 --- a/src/Microsoft.AspNet.Mvc.Core/MvcOptions.cs +++ b/src/Microsoft.AspNet.Mvc.Core/MvcOptions.cs @@ -17,6 +17,7 @@ public class MvcOptions { private AntiForgeryOptions _antiForgeryOptions = new AntiForgeryOptions(); private RazorViewEngineOptions _viewEngineOptions = new RazorViewEngineOptions(); + private int _maxModelStateErrors = 200; public MvcOptions() { @@ -94,6 +95,25 @@ public RazorViewEngineOptions ViewEngineOptions } /// + /// Gets or sets the maximum number of validation errors that are allowed by this application before further + /// errors are ignored. + /// + public int MaxModelValidationErrors + { + get { return _maxModelStateErrors; } + set + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + _maxModelStateErrors = value; + } + } + + /// + /// Get a list of the used by the /// Gets a list of the used by the /// . /// diff --git a/src/Microsoft.AspNet.Mvc.Core/MvcRouteHandler.cs b/src/Microsoft.AspNet.Mvc.Core/MvcRouteHandler.cs index 58e2a02e94..47a2dca02d 100644 --- a/src/Microsoft.AspNet.Mvc.Core/MvcRouteHandler.cs +++ b/src/Microsoft.AspNet.Mvc.Core/MvcRouteHandler.cs @@ -11,6 +11,7 @@ using Microsoft.AspNet.Routing; using Microsoft.Framework.DependencyInjection; using Microsoft.Framework.Logging; +using Microsoft.Framework.OptionsModel; namespace Microsoft.AspNet.Mvc { @@ -32,7 +33,7 @@ public string GetVirtualPath([NotNull] VirtualPathContext context) public async Task RouteAsync([NotNull] RouteContext context) { var services = context.HttpContext.RequestServices; - + // Verify if AddMvc was done before calling UseMvc // We use the MvcMarkerService to make sure if all the services were added. MvcServicesHelper.ThrowIfMvcNotRegistered(services); @@ -64,19 +65,22 @@ public async Task RouteAsync([NotNull] RouteContext context) return; } - if (actionDescriptor.RouteValueDefaults != null) - { - foreach (var kvp in actionDescriptor.RouteValueDefaults) + if (actionDescriptor.RouteValueDefaults != null) { - if (!context.RouteData.Values.ContainsKey(kvp.Key)) + foreach (var kvp in actionDescriptor.RouteValueDefaults) { - context.RouteData.Values.Add(kvp.Key, kvp.Value); + if (!context.RouteData.Values.ContainsKey(kvp.Key)) + { + context.RouteData.Values.Add(kvp.Key, kvp.Value); + } } } - } var actionContext = new ActionContext(context.HttpContext, context.RouteData, actionDescriptor); + var optionsAccessor = services.GetService>(); + actionContext.ModelState.MaxAllowedErrors = optionsAccessor.Options.MaxModelValidationErrors; + var contextAccessor = services.GetService>(); using (contextAccessor.SetContextSource(() => actionContext, PreventExchange)) { diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/CompositeModelBinder.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/CompositeModelBinder.cs index 0b63a73c7d..8bd9d52328 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/CompositeModelBinder.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/CompositeModelBinder.cs @@ -65,8 +65,7 @@ public virtual async Task BindModelAsync([NotNull] ModelBindingContext bin // Only perform validation at the root of the object graph. ValidationNode will recursively walk the graph. // Ignore ComplexModelDto since it essentially wraps the primary object. - if (newBindingContext.ModelMetadata.ContainerType == null && - newBindingContext.ModelMetadata.ModelType != typeof(ComplexModelDto)) + if (IsBindingAtRootOfObjectGraph(newBindingContext)) { // run validation and return the model // If we fell back to an empty prefix above and are dealing with simple types, @@ -92,7 +91,7 @@ public virtual async Task BindModelAsync([NotNull] ModelBindingContext bin return true; } - private async Task TryBind([NotNull] ModelBindingContext bindingContext) + private async Task TryBind(ModelBindingContext bindingContext) { // TODO: RuntimeHelpers.EnsureSufficientExecutionStack does not exist in the CoreCLR. // Protects against stack overflow for deeply nested model binding @@ -110,6 +109,16 @@ private async Task TryBind([NotNull] ModelBindingContext bindingContext) return false; } + private static bool IsBindingAtRootOfObjectGraph(ModelBindingContext bindingContext) + { + // We're at the root of the object graph if the model does does not have a container. + // This statement is true for complex types at the root twice over - once with the actual model + // and once when when it is represented by a ComplexModelDto. Ignore the latter case. + + return bindingContext.ModelMetadata.ContainerType == null && + bindingContext.ModelMetadata.ModelType != typeof(ComplexModelDto); + } + private static ModelBindingContext CreateNewBindingContext(ModelBindingContext oldBindingContext, string modelName, bool reuseValidationNode) diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/MutableObjectModelBinder.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/MutableObjectModelBinder.cs index 98599dc5de..1aeea1c7a7 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/MutableObjectModelBinder.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Binders/MutableObjectModelBinder.cs @@ -127,10 +127,10 @@ internal static EventHandler CreateNullCheckFailedHandl // var errorMessage = ModelBinderConfig.ValueRequiredErrorMessageProvider(e.ValidationContext, // modelMetadata, // incomingValue); - var errorMessage = "A value is required."; + var errorMessage = Resources.ModelBinderConfig_ValueRequired; if (errorMessage != null) { - modelState.AddModelError(validationNode.ModelStateKey, errorMessage); + modelState.TryAddModelError(validationNode.ModelStateKey, errorMessage); } } }; @@ -232,7 +232,7 @@ internal void ProcessDto(ModelBindingContext bindingContext, ComplexModelDto dto // (oddly) succeeded. if (!addedError) { - bindingContext.ModelState.AddModelError( + bindingContext.ModelState.TryAddModelError( modelStateKey, Resources.FormatMissingRequiredMember(missingRequiredProperty)); } @@ -285,7 +285,7 @@ protected virtual void SetProperty(ModelBindingContext bindingContext, var validationContext = new ModelValidationContext(bindingContext, propertyMetadata); foreach (var validationResult in requiredValidator.Validate(validationContext)) { - bindingContext.ModelState.AddModelError(modelStateKey, validationResult.Message); + bindingContext.ModelState.TryAddModelError(modelStateKey, validationResult.Message); } } } @@ -337,7 +337,7 @@ private static bool RunValidator(IModelValidator validator, var addedError = false; foreach (var validationResult in validator.Validate(validationContext)) { - bindingContext.ModelState.AddModelError(modelStateKey, validationResult.Message); + bindingContext.ModelState.TryAddModelError(modelStateKey, validationResult.Message); addedError = true; } diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/ModelBindingHelper.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/ModelBindingHelper.cs index 6f34b42c59..4f62884548 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/ModelBindingHelper.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Internal/ModelBindingHelper.cs @@ -98,11 +98,11 @@ internal static void AddModelErrorBasedOnExceptionType(ModelBindingContext bindi { if (IsFormatException(ex)) { - bindingContext.ModelState.AddModelError(bindingContext.ModelName, ex.Message); + bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, ex.Message); } else { - bindingContext.ModelState.AddModelError(bindingContext.ModelName, ex); + bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, ex); } } diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/ModelStateDictionary.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/ModelStateDictionary.cs index 57d248bfe4..733bd7f16d 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/ModelStateDictionary.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/ModelStateDictionary.cs @@ -9,53 +9,101 @@ namespace Microsoft.AspNet.Mvc.ModelBinding { + /// + /// Represents the state of an attempt to bind values from an HTTP Request to an action method, which includes + /// validation information. + /// public class ModelStateDictionary : IDictionary { private readonly IDictionary _innerDictionary; + /// + /// Initializes a new instance of the class. + /// public ModelStateDictionary() { _innerDictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); } + /// + /// Initializes a new instance of the class by using values that are copied + /// from the specified . + /// + /// The to copy values from. public ModelStateDictionary([NotNull] ModelStateDictionary dictionary) { _innerDictionary = new CopyOnWriteDictionary(dictionary, StringComparer.OrdinalIgnoreCase); + + MaxAllowedErrors = dictionary.MaxAllowedErrors; + ErrorCount = dictionary.ErrorCount; + HasRecordedMaxModelError = dictionary.HasRecordedMaxModelError; } - #region IDictionary properties + /// + /// Gets or sets the maximum allowed errors in this instance of . + /// Defaults to . + /// + /// + /// The value of this property is used to track the total number of calls to + /// and after which + /// an error is thrown for further invocations. Errors added via modifying do not + /// count towards this limit. + /// + public int MaxAllowedErrors { get; set; } = int.MaxValue; + + /// + /// Gets a flag that determines if the total number of added errors (given by ) is + /// fewer than . + /// + public bool CanAddErrors + { + get { return ErrorCount < MaxAllowedErrors; } + } + + /// + /// Gets the number of errors added to this instance of via + /// and . + /// + public int ErrorCount { get; private set; } + + /// public int Count { get { return _innerDictionary.Count; } } + /// public bool IsReadOnly { get { return _innerDictionary.IsReadOnly; } } + /// public ICollection Keys { get { return _innerDictionary.Keys; } } + /// public ICollection Values { get { return _innerDictionary.Values; } } - #endregion + /// public bool IsValid { get { return ValidationState == ModelValidationState.Valid; } } + /// public ModelValidationState ValidationState { get { return GetValidity(_innerDictionary); } } + /// public ModelState this[[NotNull] string key] { get @@ -80,20 +128,94 @@ internal IDictionary InnerDictionary get { return _innerDictionary; } } + // Flag that indiciates if TooManyModelErrorException has already been added to this dictionary. + private bool HasRecordedMaxModelError { get; set; } + + /// + /// Adds the specified to the instance + /// that is associated with the specified . + /// + /// The key of the to add errors to. + /// The to add. public void AddModelError([NotNull] string key, [NotNull] Exception exception) { - var modelState = GetModelStateForKey(key); - modelState.ValidationState = ModelValidationState.Invalid; - modelState.Errors.Add(exception); + TryAddModelError(key, exception); } + /// + /// Attempts to add the specified to the + /// instance that is associated with the specified . If the maximum number of allowed + /// errors has already been recorded, records a exception instead. + /// + /// The key of the to add errors to. + /// The to add. + /// True if the error was added, false if the dictionary has already recorded + /// at least number of errors. + /// + /// This method only allows adding up to - 1. nt + /// invocation would result in adding a to the dictionary. + /// + public bool TryAddModelError([NotNull] string key, [NotNull] Exception exception) + { + if (ErrorCount >= MaxAllowedErrors - 1) + { + EnsureMaxErrorsReachedRecorded(); + return false; + } + + ErrorCount++; + AddModelErrorCore(key, exception); + return true; + } + + /// + /// Adds the specified to the instance + /// that is associated with the specified . + /// + /// The key of the to add errors to. + /// The error message to add. public void AddModelError([NotNull] string key, [NotNull] string errorMessage) { + TryAddModelError(key, errorMessage); + } + + /// + /// Attempts to add the specified to the + /// instance that is associated with the specified . If the maximum number of allowed + /// errors has already been recorded, records a exception instead. + /// + /// The key of the to add errors to. + /// The error message to add. + /// True if the error was added, false if the dictionary has already recorded + /// at least number of errors. + /// + /// This method only allows adding up to - 1. nt + /// invocation would result in adding a to the dictionary. + /// + public bool TryAddModelError([NotNull] string key, [NotNull] string errorMessage) + { + if (ErrorCount >= MaxAllowedErrors - 1) + { + EnsureMaxErrorsReachedRecorded(); + return false; + } + + ErrorCount++; var modelState = GetModelStateForKey(key); modelState.ValidationState = ModelValidationState.Invalid; modelState.Errors.Add(errorMessage); + + return true; } + /// + /// Returns the aggregate for items starting with the + /// specified . + /// + /// The key to look up model state errors for. + /// Returns if no entries are found for the specified + /// key, if at least one instance is found with one or more model + /// state errors; otherwise. public ModelValidationState GetFieldValidationState([NotNull] string key) { var entries = DictionaryHelper.FindKeysWithPrefix(this, key); @@ -105,6 +227,11 @@ public ModelValidationState GetFieldValidationState([NotNull] string key) return GetValidity(entries); } + /// + /// Marks the for the entry with the specified + /// as . + /// + /// The key of the to mark as valid. public void MarkFieldValid([NotNull] string key) { var modelState = GetModelStateForKey(key); @@ -116,6 +243,11 @@ public void MarkFieldValid([NotNull] string key) modelState.ValidationState = ModelValidationState.Valid; } + /// + /// Copies the values from the specified into this instance, overwriting + /// existing values if keys are the same. + /// + /// The to copy values from. public void Merge(ModelStateDictionary dictionary) { if (dictionary == null) @@ -129,6 +261,12 @@ public void Merge(ModelStateDictionary dictionary) } } + /// + /// Sets the value for the with the specified to the + /// specified . + /// + /// The key for the entry. + /// The value to assign. public void SetModelValue([NotNull] string key, [NotNull] ValueProviderResult value) { GetModelStateForKey(key).Value = value; @@ -165,61 +303,88 @@ private static ModelValidationState GetValidity(IEnumerable public void Add(KeyValuePair item) { Add(item.Key, item.Value); } + /// public void Add([NotNull] string key, [NotNull] ModelState value) { _innerDictionary.Add(key, value); } + /// public void Clear() { _innerDictionary.Clear(); } + /// public bool Contains(KeyValuePair item) { return _innerDictionary.Contains(item); } + /// public bool ContainsKey([NotNull] string key) { return _innerDictionary.ContainsKey(key); } + /// public void CopyTo([NotNull] KeyValuePair[] array, int arrayIndex) { _innerDictionary.CopyTo(array, arrayIndex); } + /// public bool Remove(KeyValuePair item) { return _innerDictionary.Remove(item); } + /// public bool Remove([NotNull] string key) { return _innerDictionary.Remove(key); } + /// public bool TryGetValue([NotNull] string key, out ModelState value) { return _innerDictionary.TryGetValue(key, out value); } + /// public IEnumerator> GetEnumerator() { return _innerDictionary.GetEnumerator(); } + /// IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); } - #endregion } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Properties/Resources.Designer.cs index 8f6da0b7ee..feb331ddc9 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Properties/Resources.Designer.cs @@ -394,6 +394,22 @@ internal static string FormatDataAnnotationsModelMetadataProvider_UnreadableProp return string.Format(CultureInfo.CurrentCulture, GetString("DataAnnotationsModelMetadataProvider_UnreadableProperty"), p0, p1); } + /// + /// The maximum number of allowed model errors has been reached. + /// + internal static string ModelStateDictionary_MaxModelStateErrors + { + get { return GetString("ModelStateDictionary_MaxModelStateErrors"); } + } + + /// + /// The maximum number of allowed model errors has been reached. + /// + internal static string FormatModelStateDictionary_MaxModelStateErrors() + { + return GetString("ModelStateDictionary_MaxModelStateErrors"); + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.resx b/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.resx index 5f322e7d89..7d53d5b543 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.resx +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Resources.resx @@ -189,4 +189,7 @@ {0} has a DisplayColumn attribute for {1}, but property {1} does not have a public 'get' method. + + The maximum number of allowed model errors has been reached. + \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/TooManyModelErrorsException.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/TooManyModelErrorsException.cs new file mode 100644 index 0000000000..db88243b18 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/TooManyModelErrorsException.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNet.Mvc.ModelBinding +{ + /// + /// The that is thrown when too many model errors are encountered. + /// + public class TooManyModelErrorsException : Exception + { + /// + /// Creates a new instance of with the specified + /// exception . + /// + /// The message that describes the error. + public TooManyModelErrorsException([NotNull] string message) + : base(message) + { + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.ModelBinding/Validation/ModelValidationNode.cs b/src/Microsoft.AspNet.Mvc.ModelBinding/Validation/ModelValidationNode.cs index f49c2dad23..d81e209b20 100644 --- a/src/Microsoft.AspNet.Mvc.ModelBinding/Validation/ModelValidationNode.cs +++ b/src/Microsoft.AspNet.Mvc.ModelBinding/Validation/ModelValidationNode.cs @@ -102,9 +102,9 @@ public void Validate(ModelValidationContext validationContext) public void Validate([NotNull] ModelValidationContext validationContext, ModelValidationNode parentNode) { - if (SuppressValidation) + if (SuppressValidation || !validationContext.ModelState.CanAddErrors) { - // no-op + // Short circuit if validation does not need to be applied or if we've reached the max number of validation errors. return; } @@ -171,7 +171,7 @@ private void ValidateProperties(ModelValidationContext validationContext) { var thisErrorKey = ModelBindingHelper.CreatePropertyModelName(propertyKeyRoot, propertyResult.MemberName); - modelState.AddModelError(thisErrorKey, propertyResult.Message); + modelState.TryAddModelError(thisErrorKey, propertyResult.Message); } } } @@ -194,7 +194,7 @@ private void ValidateThis(ModelValidationContext validationContext, ModelValidat ModelMetadata.GetDisplayName()); if (parentNode == null && ModelMetadata.Model == null) { - modelState.AddModelError(modelStateKey, Resources.Validation_ValueNotFound); + modelState.TryAddModelError(modelStateKey, Resources.Validation_ValueNotFound); return; } @@ -207,7 +207,7 @@ private void ValidateThis(ModelValidationContext validationContext, ModelValidat { var currentModelStateKey = ModelBindingHelper.CreatePropertyModelName(ModelStateKey, validationResult.MemberName); - modelState.AddModelError(currentModelStateKey, validationResult.Message); + modelState.TryAddModelError(currentModelStateKey, validationResult.Message); } } } diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Formatters/JsonInputFormatterTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/Formatters/JsonInputFormatterTest.cs index d60e520487..f1c5c2933a 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/Formatters/JsonInputFormatterTest.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/Formatters/JsonInputFormatterTest.cs @@ -144,6 +144,30 @@ public async Task ReadAsync_AddsModelValidationErrorsToModelState_WhenCaptureErr actionContext.ModelState["Age"].Errors[0].Exception.Message); } + [Fact] + public async Task ReadAsync_UsesTryAddModelValidationErrorsToModelState_WhenCaptureErrorsIsSet() + { + // Arrange + var content = "{name: 'Person Name', Age: 'not-an-age'}"; + var formatter = new JsonInputFormatter { CaptureDeserilizationErrors = true }; + var contentBytes = Encoding.UTF8.GetBytes(content); + + var actionContext = GetActionContext(contentBytes); + var metadata = new EmptyModelMetadataProvider().GetMetadataForType(null, typeof(User)); + var context = new InputFormatterContext(actionContext, metadata.ModelType); + actionContext.ModelState.MaxAllowedErrors = 3; + actionContext.ModelState.AddModelError("key1", "error1"); + actionContext.ModelState.AddModelError("key2", "error2"); + + // Act + var model = await formatter.ReadAsync(context); + + // Assert + Assert.False(actionContext.ModelState.ContainsKey("age")); + var error = Assert.Single(actionContext.ModelState[""].Errors); + Assert.IsType(error.Exception); + } + private static ActionContext GetActionContext(byte[] contentBytes, string contentType = "application/xml") { diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/MvcOptionsTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/MvcOptionsTests.cs index a03dd6b6f6..efcc3f2cbf 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/MvcOptionsTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/MvcOptionsTests.cs @@ -16,8 +16,19 @@ public void AntiForgeryOptions_SettingNullValue_Throws() // Act & Assert var ex = Assert.Throws(() => options.AntiForgeryOptions = null); - Assert.Equal("The 'AntiForgeryOptions' property of 'Microsoft.AspNet.Mvc.MvcOptions' must not be null." + + Assert.Equal("The 'AntiForgeryOptions' property of 'Microsoft.AspNet.Mvc.MvcOptions' must not be null." + "\r\nParameter name: value", ex.Message); } + + [Fact] + public void MaxValidationError_ThrowsIfValueIsOutOfRange() + { + // Arrange + var options = new MvcOptions(); + + // Act & Assert + var ex = Assert.Throws(() => options.MaxModelValidationErrors = -1); + Assert.Equal("value", ex.ParamName); + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/MvcRouteHandlerTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/MvcRouteHandlerTests.cs index 0faf10ca08..7792225df1 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/MvcRouteHandlerTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/MvcRouteHandlerTests.cs @@ -10,6 +10,7 @@ using Microsoft.AspNet.Routing; using Microsoft.Framework.DependencyInjection; using Microsoft.Framework.Logging; +using Microsoft.Framework.OptionsModel; using Moq; using Xunit; @@ -18,7 +19,7 @@ namespace Microsoft.AspNet.Mvc public class MvcRouteHandlerTests { [Fact] - public async void RouteAsync_Success_LogsCorrectValues() + public async Task RouteAsync_Success_LogsCorrectValues() { // Arrange var sink = new TestSink(); @@ -56,7 +57,7 @@ public async void RouteAsync_Success_LogsCorrectValues() } [Fact] - public async void RouteAsync_FailOnNoAction_LogsCorrectValues() + public async Task RouteAsync_FailOnNoAction_LogsCorrectValues() { // Arrange var sink = new TestSink(); @@ -100,7 +101,7 @@ public async void RouteAsync_FailOnNoAction_LogsCorrectValues() } [Fact] - public async void RouteAsync_FailOnNoInvoker_LogsCorrectValues() + public async Task RouteAsync_FailOnNoInvoker_LogsCorrectValues() { // Arrange var sink = new TestSink(); @@ -144,10 +145,47 @@ await Assert.ThrowsAsync(async () => Assert.Equal(false, values.Handled); } + [Fact] + public async Task RouteAsync_SetsMaxErrorCountOnModelStateDictionary() + { + // Arrange + var expected = 199; + var optionsAccessor = new Mock>(); + var options = new MvcOptions + { + MaxModelValidationErrors = expected + }; + optionsAccessor.SetupGet(o => o.Options) + .Returns(options); + + var invoked = false; + var mockInvokerFactory = new Mock(); + mockInvokerFactory.Setup(f => f.CreateInvoker(It.IsAny())) + .Callback(c => + { + Assert.Equal(expected, c.ModelState.MaxAllowedErrors); + invoked = true; + }) + .Returns(Mock.Of()); + + var context = CreateRouteContext( + invokerFactory: mockInvokerFactory.Object, + optionsAccessor: optionsAccessor.Object); + + var handler = new MvcRouteHandler(); + + // Act + await handler.RouteAsync(context); + + // Assert + Assert.True(invoked); + } + private RouteContext CreateRouteContext( IActionSelector actionSelector = null, IActionInvokerFactory invokerFactory = null, - ILoggerFactory loggerFactory = null) + ILoggerFactory loggerFactory = null, + IOptionsAccessor optionsAccessor = null) { var mockContextAccessor = new Mock>(); mockContextAccessor.Setup(c => c.SetContextSource( @@ -185,6 +223,15 @@ private RouteContext CreateRouteContext( loggerFactory = NullLoggerFactory.Instance; } + if (optionsAccessor == null) + { + var mockOptionsAccessor = new Mock>(); + mockOptionsAccessor.SetupGet(o => o.Options) + .Returns(new MvcOptions()); + + optionsAccessor = mockOptionsAccessor.Object; + } + var httpContext = new Mock(); httpContext.Setup(h => h.RequestServices.GetService(typeof(IContextAccessor))) .Returns(mockContextAccessor.Object); @@ -196,6 +243,8 @@ private RouteContext CreateRouteContext( .Returns(loggerFactory); httpContext.Setup(h => h.RequestServices.GetService(typeof(IEnumerable))) .Returns(new List { new MvcMarkerService() }); + httpContext.Setup(h => h.RequestServices.GetService(typeof(IOptionsAccessor))) + .Returns(optionsAccessor); return new RouteContext(httpContext.Object); } diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTests.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTests.cs index 4e6033df5e..6e8d99f9c5 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTests.cs +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/ModelBindingTests.cs @@ -2,10 +2,13 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; +using System.Linq; using System.Net; using System.Threading.Tasks; using Microsoft.AspNet.Builder; using Microsoft.AspNet.TestHost; +using Newtonsoft.Json; using Xunit; namespace Microsoft.AspNet.Mvc.FunctionalTests @@ -44,5 +47,48 @@ public async Task ModelBindingBindsEmptyStringsToByteArrays() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("\0", await response.Content.ReadAsStringAsync()); } + + [Fact] + public async Task ModelBinding_LimitsErrorsToMaxErrorCount() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + var queryString = string.Join("=&", Enumerable.Range(0, 10).Select(i => "field" + i)); + + // Act + var response = await client.GetStringAsync("http://localhost/Home/ModelWithTooManyValidationErrors?" + queryString); + + //Assert + var json = JsonConvert.DeserializeObject>(response); + // 8 is the value of MaxModelValidationErrors for the application being tested. + Assert.Equal(8, json.Count); + Assert.Equal("The Field1 field is required.", json["Field1.Field1"]); + Assert.Equal("The Field2 field is required.", json["Field1.Field2"]); + Assert.Equal("The Field3 field is required.", json["Field1.Field3"]); + Assert.Equal("The Field1 field is required.", json["Field2.Field1"]); + Assert.Equal("The Field2 field is required.", json["Field2.Field2"]); + Assert.Equal("The Field3 field is required.", json["Field2.Field3"]); + Assert.Equal("The Field1 field is required.", json["Field3.Field1"]); + Assert.Equal("The maximum number of allowed model errors has been reached.", json[""]); + } + + [Fact] + public async Task ModelBinding_ValidatesAllPropertiesInModel() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetStringAsync("http://localhost/Home/ModelWithFewValidationErrors?model="); + + //Assert + var json = JsonConvert.DeserializeObject>(response); + Assert.Equal(3, json.Count); + Assert.Equal("The Field1 field is required.", json["model.Field1"]); + Assert.Equal("The Field2 field is required.", json["model.Field2"]); + Assert.Equal("The Field3 field is required.", json["model.Field3"]); + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/CompositeModelBinderTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/CompositeModelBinderTest.cs index cfe67de69b..608a799bd8 100644 --- a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/CompositeModelBinderTest.cs +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Binders/CompositeModelBinderTest.cs @@ -266,6 +266,37 @@ public async Task BindModel_WithDefaultValidators_ValidatesInstance() Assert.Equal("Password does not meet complexity requirements.", error.ErrorMessage); } + [Fact] + public async Task BindModel_UsesTryAddModelError() + { + // Arrange + var validatorProvider = new DataAnnotationsModelValidatorProvider(); + var binder = CreateBinderWithDefaults(); + var valueProvider = new SimpleHttpValueProvider + { + { "user.password", "password" }, + { "user.confirmpassword", "password2" }, + }; + var bindingContext = CreateBindingContext(binder, valueProvider, typeof(User), validatorProvider); + bindingContext.ModelState.MaxAllowedErrors = 2; + bindingContext.ModelState.AddModelError("key1", "error1"); + bindingContext.ModelName = "user"; + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + var modelState = bindingContext.ModelState["user.confirmpassword"]; + Assert.Empty(modelState.Errors); + + modelState = bindingContext.ModelState["user"]; + Assert.Empty(modelState.Errors); + + var error = Assert.Single(bindingContext.ModelState[""].Errors); + Assert.IsType(error.Exception); + } + + private static ModelBindingContext CreateBindingContext(IModelBinder binder, IValueProvider valueProvider, Type type, diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Validation/ModelStateDictionaryTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Validation/ModelStateDictionaryTest.cs index 176759bff0..72aca9c32c 100644 --- a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Validation/ModelStateDictionaryTest.cs +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Validation/ModelStateDictionaryTest.cs @@ -26,6 +26,7 @@ public void CopyConstructor_CopiesModelStateData() var target = new ModelStateDictionary(source); // Assert + Assert.Equal(0, target.ErrorCount); Assert.Equal(1, target.Count); Assert.Same(modelState, target["key"]); Assert.IsType>(target.InnerDictionary); @@ -41,6 +42,7 @@ public void AddModelErrorCreatesModelStateIfNotPresent() dictionary.AddModelError("some key", "some error"); // Assert + Assert.Equal(1, dictionary.ErrorCount); var kvp = Assert.Single(dictionary); Assert.Equal("some key", kvp.Key); var error = Assert.Single(kvp.Value.Errors); @@ -59,6 +61,7 @@ public void AddModelErrorUsesExistingModelStateIfPresent() dictionary.AddModelError("some key", ex); // Assert + Assert.Equal(2, dictionary.ErrorCount); var kvp = Assert.Single(dictionary); Assert.Equal("some key", kvp.Key); @@ -365,6 +368,137 @@ public void GetFieldValidity_ReturnsValid_IfAllKeysAreValid() Assert.Equal(ModelValidationState.Valid, validationState); } + [Fact] + public void AddModelError_WithErrorString_AddsTooManyModelErrors_WhenMaxErrorsIsReached() + { + // Arrange + var expected = "The maximum number of allowed model errors has been reached."; + var dictionary = new ModelStateDictionary + { + MaxAllowedErrors = 5 + }; + dictionary.AddModelError("key1", "error1"); + dictionary.AddModelError("key2", new Exception()); + dictionary.AddModelError("key3", new Exception()); + dictionary.AddModelError("key4", "error4"); + dictionary.AddModelError("key5", "error5"); + dictionary.AddModelError("key6", "error6"); + + // Act and Assert + Assert.False(dictionary.CanAddErrors); + Assert.Equal(5, dictionary.ErrorCount); + var error = Assert.Single(dictionary[""].Errors); + Assert.IsType(error.Exception); + Assert.Equal(expected, error.Exception.Message); + } + + [Fact] + public void TryAddModelError_WithErrorString_ReturnsFalse_AndAddsMaxModelErrorMessage() + { + // Arrange + var expected = "The maximum number of allowed model errors has been reached."; + var dictionary = new ModelStateDictionary + { + MaxAllowedErrors = 3 + }; + + // Act and Assert + var result = dictionary.TryAddModelError("key1", "error1"); + Assert.True(result); + + result = dictionary.TryAddModelError("key2", new Exception()); + Assert.True(result); + + result = dictionary.TryAddModelError("key3", "error3"); + Assert.False(result); + + result = dictionary.TryAddModelError("key4", "error4"); + Assert.False(result); + + Assert.False(dictionary.CanAddErrors); + Assert.Equal(3, dictionary.ErrorCount); + Assert.Equal(3, dictionary.Count); + + var error = Assert.Single(dictionary[""].Errors); + Assert.IsType(error.Exception); + Assert.Equal(expected, error.Exception.Message); + } + + [Fact] + public void AddModelError_WithException_AddsTooManyModelError_WhenMaxErrorIsReached() + { + // Arrange + var expected = "The maximum number of allowed model errors has been reached."; + var dictionary = new ModelStateDictionary + { + MaxAllowedErrors = 4 + }; + dictionary.AddModelError("key1", new Exception()); + dictionary.AddModelError("key2", "error2"); + dictionary.AddModelError("key3", "error3"); + dictionary.AddModelError("key3", new Exception()); + dictionary.AddModelError("key4", new InvalidOperationException()); + dictionary.AddModelError("key5", new FormatException()); + + // Act and Assert + Assert.False(dictionary.CanAddErrors); + Assert.Equal(4, dictionary.ErrorCount); + Assert.Equal(4, dictionary.Count); + var error = Assert.Single(dictionary[""].Errors); + Assert.IsType(error.Exception); + Assert.Equal(expected, error.Exception.Message); + } + + [Fact] + public void TryAddModelError_WithException_ReturnsFalse_AndAddsMaxModelErrorMessage() + { + // Arrange + var expected = "The maximum number of allowed model errors has been reached."; + var dictionary = new ModelStateDictionary + { + MaxAllowedErrors = 3 + }; + + // Act and Assert + var result = dictionary.TryAddModelError("key1", "error1"); + Assert.True(result); + + result = dictionary.TryAddModelError("key2", new Exception()); + Assert.True(result); + + result = dictionary.TryAddModelError("key3", new Exception()); + Assert.False(result); + + Assert.Equal(3, dictionary.Count); + var error = Assert.Single(dictionary[""].Errors); + Assert.IsType(error.Exception); + Assert.Equal(expected, error.Exception.Message); + } + + [Fact] + public void ModelStateDictionary_TracksAddedErrorsOverCopyConstructor() + { + // Arrange + var expected = "The maximum number of allowed model errors has been reached."; + var dictionary = new ModelStateDictionary + { + MaxAllowedErrors = 3 + }; + + // Act + dictionary.AddModelError("key1", "error1"); + dictionary.TryAddModelError("key3", new Exception()); + + var copy = new ModelStateDictionary(dictionary); + copy.AddModelError("key2", "error2"); + + // Assert + Assert.Equal(3, copy.Count); + var error = Assert.Single(copy[""].Errors); + Assert.IsType(error.Exception); + Assert.Equal(expected, error.Exception.Message); + } + private static ValueProviderResult GetValueProviderResult(object rawValue = null, string attemptedValue = null) { return new ValueProviderResult(rawValue ?? "some value", diff --git a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Validation/ModelValidationNodeTest.cs b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Validation/ModelValidationNodeTest.cs index e3b9b06d7c..7c3b1f988e 100644 --- a/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Validation/ModelValidationNodeTest.cs +++ b/test/Microsoft.AspNet.Mvc.ModelBinding.Test/Validation/ModelValidationNodeTest.cs @@ -278,6 +278,39 @@ public void Validate_ValidateAllProperties_AddsValidationErrors() Assert.False(context.ModelState.ContainsKey("theKey")); } + [Fact] + [ReplaceCulture] + public void Validate_ShortCircuits_IfModelStateHasReachedMaxNumberOfErrors() + { + // Arrange + var model = new ValidateAllPropertiesModel + { + RequiredString = null /* error */, + RangedInt = 0 /* error */, + ValidString = "cat" /* error */ + }; + + var modelMetadata = GetModelMetadata(model); + var node = new ModelValidationNode(modelMetadata, "theKey") + { + ValidateAllProperties = true + }; + var context = CreateContext(modelMetadata); + context.ModelState.MaxAllowedErrors = 3; + context.ModelState.AddModelError("somekey", "error text"); + + // Act + node.Validate(context); + + // Assert + Assert.Equal(3, context.ModelState.Count); + Assert.IsType(context.ModelState[""].Errors[0].Exception); + Assert.Equal("The RequiredString field is required.", + context.ModelState["theKey.RequiredString"].Errors[0].ErrorMessage); + Assert.False(context.ModelState.ContainsKey("theKey.RangedInt")); + Assert.False(context.ModelState.ContainsKey("theKey.ValidString")); + } + private static ModelMetadata GetModelMetadata() { return new EmptyModelMetadataProvider().GetMetadataForType(null, typeof(object)); diff --git a/test/WebSites/ModelBindingWebSite/Controllers/HomeController.cs b/test/WebSites/ModelBindingWebSite/Controllers/HomeController.cs index b4abf7e023..5284020409 100644 --- a/test/WebSites/ModelBindingWebSite/Controllers/HomeController.cs +++ b/test/WebSites/ModelBindingWebSite/Controllers/HomeController.cs @@ -1,7 +1,10 @@ // Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Collections.Generic; using Microsoft.AspNet.Mvc; +using System.Linq; +using ModelBindingWebSite.Models; namespace ModelBindingWebSite.Controllers { @@ -12,5 +15,32 @@ public IActionResult Index(byte[] byteValues) { return Content(System.Text.Encoding.UTF8.GetString(byteValues)); } + + public object ModelWithTooManyValidationErrors(LargeModelWithValidation model) + { + return CreateValidationDictionary(); + } + + public object ModelWithFewValidationErrors(ModelWithValidation model) + { + return CreateValidationDictionary(); + } + + private Dictionary CreateValidationDictionary() + { + var result = new Dictionary(); + foreach (var item in ModelState) + { + var error = item.Value.Errors.SingleOrDefault(); + if (error != null) + { + var value = error.Exception != null ? error.Exception.Message : + error.ErrorMessage; + result.Add(item.Key, value); + } + } + + return result; + } } } \ No newline at end of file diff --git a/test/WebSites/ModelBindingWebSite/Models/LargeModelWithValidation.cs b/test/WebSites/ModelBindingWebSite/Models/LargeModelWithValidation.cs new file mode 100644 index 0000000000..62d08e2df1 --- /dev/null +++ b/test/WebSites/ModelBindingWebSite/Models/LargeModelWithValidation.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.ComponentModel.DataAnnotations; + +namespace ModelBindingWebSite.Models +{ + public class LargeModelWithValidation + { + [Required] + public ModelWithValidation Field1 { get; set; } + + [Required] + public ModelWithValidation Field2 { get; set; } + + [Required] + public ModelWithValidation Field3 { get; set; } + + [Required] + public ModelWithValidation Field4 { get; set; } + } +} \ No newline at end of file diff --git a/test/WebSites/ModelBindingWebSite/Models/ModelWithValidation.cs b/test/WebSites/ModelBindingWebSite/Models/ModelWithValidation.cs new file mode 100644 index 0000000000..51c2a4f0e3 --- /dev/null +++ b/test/WebSites/ModelBindingWebSite/Models/ModelWithValidation.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.ComponentModel.DataAnnotations; + +namespace ModelBindingWebSite +{ + public class ModelWithValidation + { + [Required] + public string Field1 { get; set; } + + [Required] + public string Field2 { get; set; } + + [Required] + public string Field3 { get; set; } + } +} \ No newline at end of file diff --git a/test/WebSites/ModelBindingWebSite/Startup.cs b/test/WebSites/ModelBindingWebSite/Startup.cs index e5436b71c7..52546cf15b 100644 --- a/test/WebSites/ModelBindingWebSite/Startup.cs +++ b/test/WebSites/ModelBindingWebSite/Startup.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using Microsoft.AspNet.Mvc; using Microsoft.AspNet.Builder; using Microsoft.AspNet.Routing; using Microsoft.Framework.DependencyInjection; @@ -17,7 +18,11 @@ public void Configure(IApplicationBuilder app) app.UseServices(services => { // Add MVC services to the services container - services.AddMvc(configuration); + services.AddMvc(configuration) + .SetupOptions(m => + { + m.MaxModelValidationErrors = 8; + }); }); // Add MVC to the request pipeline