Skip to content

Commit 1ff9b04

Browse files
committed
More tests and add DisableValidationFilter
1 parent fbe46cf commit 1ff9b04

File tree

9 files changed

+344
-12
lines changed

9 files changed

+344
-12
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Http.Metadata;
5+
6+
/// <summary>
7+
/// A marker interface which can be used to identify metadata that disables validation
8+
/// on a given endpoint.
9+
/// </summary>
10+
public interface IDisableValidationMetadata
11+
{
12+
}

src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#nullable enable
22
abstract Microsoft.AspNetCore.Http.Validation.ValidatableParameterInfo.GetValidationAttributes() -> System.ComponentModel.DataAnnotations.ValidationAttribute![]!
33
abstract Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo.GetValidationAttributes() -> System.ComponentModel.DataAnnotations.ValidationAttribute![]!
4+
Microsoft.AspNetCore.Http.Metadata.IDisableValidationMetadata
45
Microsoft.AspNetCore.Http.ProducesResponseTypeMetadata.Description.get -> string?
56
Microsoft.AspNetCore.Http.ProducesResponseTypeMetadata.Description.set -> void
67
Microsoft.AspNetCore.Http.Metadata.IProducesResponseTypeMetadata.Description.get -> string?

src/Http/Http.Abstractions/test/ValidatableTypeInfoTests.cs renamed to src/Http/Http.Abstractions/test/Validation/ValidatableTypeInfoTests.cs

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,143 @@ [new RequiredAttribute()]),
354354
Assert.Contains("Maximum validation depth of 3 exceeded at 'Children[0].Parent.Children[0]'. This is likely caused by a circular reference in the object graph. Consider increasing the MaxDepth in ValidationOptions if deeper validation is required.", exception.Message);
355355
}
356356

357+
[Fact]
358+
public async Task Validate_HandlesCustomValidationAttributes()
359+
{
360+
// Arrange
361+
var productType = new TestValidatableTypeInfo(
362+
typeof(Product),
363+
[
364+
CreatePropertyInfo(typeof(Product), typeof(string), "SKU", "SKU", false, false, true, false, [new RequiredAttribute(), new CustomSkuValidationAttribute()]),
365+
],
366+
false
367+
);
368+
369+
var context = new ValidatableContext
370+
{
371+
ValidationOptions = new TestValidationOptions(new Dictionary<Type, ValidatableTypeInfo>
372+
{
373+
{ typeof(Product), productType }
374+
})
375+
};
376+
377+
var product = new Product { SKU = "INVALID-SKU" };
378+
context.ValidationContext = new ValidationContext(product);
379+
380+
// Act
381+
await productType.Validate(product, context);
382+
383+
// Assert
384+
Assert.NotNull(context.ValidationErrors);
385+
var error = Assert.Single(context.ValidationErrors);
386+
Assert.Equal("SKU", error.Key);
387+
Assert.Equal("SKU must start with 'PROD-'.", error.Value.First());
388+
}
389+
390+
[Fact]
391+
public async Task Validate_HandlesMultipleErrorsOnSameProperty()
392+
{
393+
// Arrange
394+
var userType = new TestValidatableTypeInfo(
395+
typeof(User),
396+
[
397+
CreatePropertyInfo(typeof(User), typeof(string), "Password", "Password", false, false, true, false,
398+
[
399+
new RequiredAttribute(),
400+
new MinLengthAttribute(8) { ErrorMessage = "Password must be at least 8 characters." },
401+
new PasswordComplexityAttribute()
402+
])
403+
],
404+
false
405+
);
406+
407+
var context = new ValidatableContext
408+
{
409+
ValidationOptions = new TestValidationOptions(new Dictionary<Type, ValidatableTypeInfo>
410+
{
411+
{ typeof(User), userType }
412+
})
413+
};
414+
415+
var user = new User { Password = "abc" }; // Too short and not complex enough
416+
context.ValidationContext = new ValidationContext(user);
417+
418+
// Act
419+
await userType.Validate(user, context);
420+
421+
// Assert
422+
Assert.NotNull(context.ValidationErrors);
423+
Assert.Single(context.ValidationErrors.Keys); // Only the "Password" key
424+
Assert.Equal(2, context.ValidationErrors["Password"].Length); // But with 2 errors
425+
Assert.Contains("Password must be at least 8 characters.", context.ValidationErrors["Password"]);
426+
Assert.Contains("Password must contain at least one number and one special character.", context.ValidationErrors["Password"]);
427+
}
428+
429+
[Fact]
430+
public async Task Validate_HandlesMultiLevelInheritance()
431+
{
432+
// Arrange
433+
var baseType = new TestValidatableTypeInfo(
434+
typeof(BaseEntity),
435+
[
436+
CreatePropertyInfo(typeof(BaseEntity), typeof(Guid), "Id", "Id", false, false, false, false, [])
437+
],
438+
false
439+
);
440+
441+
var intermediateType = new TestValidatableTypeInfo(
442+
typeof(IntermediateEntity),
443+
[
444+
CreatePropertyInfo(typeof(IntermediateEntity), typeof(DateTime), "CreatedAt", "CreatedAt", false, false, false, false, [new PastDateAttribute()])
445+
],
446+
false,
447+
[typeof(BaseEntity)]
448+
);
449+
450+
var derivedType = new TestValidatableTypeInfo(
451+
typeof(DerivedEntity),
452+
[
453+
CreatePropertyInfo(typeof(DerivedEntity), typeof(string), "Name", "Name", false, false, true, false, [new RequiredAttribute()])
454+
],
455+
false,
456+
[typeof(IntermediateEntity)]
457+
);
458+
459+
var context = new ValidatableContext
460+
{
461+
ValidationOptions = new TestValidationOptions(new Dictionary<Type, ValidatableTypeInfo>
462+
{
463+
{ typeof(BaseEntity), baseType },
464+
{ typeof(IntermediateEntity), intermediateType },
465+
{ typeof(DerivedEntity), derivedType }
466+
})
467+
};
468+
469+
var entity = new DerivedEntity
470+
{
471+
Name = "", // Invalid: required
472+
CreatedAt = DateTime.Now.AddDays(1) // Invalid: future date
473+
};
474+
context.ValidationContext = new ValidationContext(entity);
475+
476+
// Act
477+
await derivedType.Validate(entity, context);
478+
479+
// Assert
480+
Assert.NotNull(context.ValidationErrors);
481+
Assert.Collection(context.ValidationErrors,
482+
kvp =>
483+
{
484+
Assert.Equal("Name", kvp.Key);
485+
Assert.Equal("The Name field is required.", kvp.Value.First());
486+
},
487+
kvp =>
488+
{
489+
Assert.Equal("CreatedAt", kvp.Key);
490+
Assert.Equal("Date must be in the past.", kvp.Value.First());
491+
});
492+
}
493+
357494
private ValidatablePropertyInfo CreatePropertyInfo(
358495
Type containingType,
359496
Type propertyType,
@@ -436,6 +573,76 @@ private class TreeNode
436573
public List<TreeNode> Children { get; set; } = [];
437574
}
438575

576+
private class Product
577+
{
578+
public string SKU { get; set; } = string.Empty;
579+
}
580+
581+
private class User
582+
{
583+
public string Password { get; set; } = string.Empty;
584+
}
585+
586+
private class BaseEntity
587+
{
588+
public Guid Id { get; set; } = Guid.NewGuid();
589+
}
590+
591+
private class IntermediateEntity : BaseEntity
592+
{
593+
public DateTime CreatedAt { get; set; }
594+
}
595+
596+
private class DerivedEntity : IntermediateEntity
597+
{
598+
public string Name { get; set; } = string.Empty;
599+
}
600+
601+
private class PastDateAttribute : ValidationAttribute
602+
{
603+
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
604+
{
605+
if (value is DateTime date && date > DateTime.Now)
606+
{
607+
return new ValidationResult("Date must be in the past.");
608+
}
609+
610+
return ValidationResult.Success;
611+
}
612+
}
613+
614+
private class CustomSkuValidationAttribute : ValidationAttribute
615+
{
616+
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
617+
{
618+
if (value is string sku && !sku.StartsWith("PROD-", StringComparison.Ordinal))
619+
{
620+
return new ValidationResult("SKU must start with 'PROD-'.");
621+
}
622+
623+
return ValidationResult.Success;
624+
}
625+
}
626+
627+
private class PasswordComplexityAttribute : ValidationAttribute
628+
{
629+
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
630+
{
631+
if (value is string password)
632+
{
633+
bool hasDigit = password.Any(c => char.IsDigit(c));
634+
bool hasSpecial = password.Any(c => !char.IsLetterOrDigit(c));
635+
636+
if (!hasDigit || !hasSpecial)
637+
{
638+
return new ValidationResult("Password must contain at least one number and one special character.");
639+
}
640+
}
641+
642+
return ValidationResult.Success;
643+
}
644+
}
645+
439646
// Test implementations
440647
private class TestValidatablePropertyInfo : ValidatablePropertyInfo
441648
{
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.ComponentModel.DataAnnotations;
5+
using System.Text;
6+
using Microsoft.AspNetCore.Builder;
7+
using Microsoft.AspNetCore.Http.Metadata;
8+
using Microsoft.AspNetCore.InternalTesting;
9+
using Microsoft.AspNetCore.Routing;
10+
using Microsoft.Extensions.DependencyInjection;
11+
12+
namespace Microsoft.AspNetCore.Http.Extensions.Tests;
13+
14+
public class ValidationEndpointConventionBuilderExtensionsTests : LoggedTest
15+
{
16+
[Fact]
17+
public async Task DisableValidation_PreventsValidationFilterRegistration()
18+
{
19+
// Arrange
20+
var services = new ServiceCollection();
21+
services.AddValidation();
22+
services.AddSingleton(LoggerFactory);
23+
var serviceProvider = services.BuildServiceProvider();
24+
25+
var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider));
26+
27+
// Act - Create two endpoints - one with validation disabled, one without
28+
var regularBuilder = builder.MapGet("test-enabled", ([Range(5, 10)] int param) => "Validation enabled here.");
29+
var disabledBuilder = builder.MapGet("test-disabled", ([Range(5, 10)] int param) => "Validation disabled here.");
30+
31+
disabledBuilder.DisableValidation();
32+
33+
// Build the endpoints
34+
var dataSource = Assert.Single(builder.DataSources);
35+
var endpoints = dataSource.Endpoints;
36+
37+
// Assert
38+
Assert.Equal(2, endpoints.Count);
39+
40+
// Get filter factories from both endpoints
41+
var regularEndpoint = endpoints[0];
42+
var disabledEndpoint = endpoints[1];
43+
44+
// Verify the disabled endpoint has the IDisableValidationMetadata
45+
Assert.Contains(disabledEndpoint.Metadata, m => m is IDisableValidationMetadata);
46+
47+
// Verify that invalid arguments on the disabled endpoint do not trigger validation
48+
var context = new DefaultHttpContext
49+
{
50+
RequestServices = serviceProvider
51+
};
52+
context.Request.Method = "GET";
53+
context.Request.QueryString = new QueryString("?param=15");
54+
var ms = new MemoryStream();
55+
context.Response.Body = ms;
56+
57+
await disabledEndpoint.RequestDelegate(context);
58+
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
59+
Assert.Equal("Validation disabled here.", Encoding.UTF8.GetString(ms.ToArray()));
60+
61+
context = new DefaultHttpContext
62+
{
63+
RequestServices = serviceProvider
64+
};
65+
context.Request.Method = "GET";
66+
context.Request.QueryString = new QueryString("?param=15");
67+
await regularEndpoint.RequestDelegate(context);
68+
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
69+
}
70+
71+
private class DefaultEndpointRouteBuilder(IApplicationBuilder applicationBuilder) : IEndpointRouteBuilder
72+
{
73+
private IApplicationBuilder ApplicationBuilder { get; } = applicationBuilder ?? throw new ArgumentNullException(nameof(applicationBuilder));
74+
public IApplicationBuilder CreateApplicationBuilder() => ApplicationBuilder.New();
75+
public ICollection<EndpointDataSource> DataSources { get; } = [];
76+
public IServiceProvider ServiceProvider => ApplicationBuilder.ApplicationServices;
77+
}
78+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.Http.Metadata;
5+
6+
namespace Microsoft.AspNetCore.Builder;
7+
8+
/// <summary>
9+
/// Extension methods for <see cref="IEndpointConventionBuilder"/> to interact with
10+
/// parameter validation features.
11+
/// </summary>
12+
public static class ValidationEndpointConventionBuilderExtensions
13+
{
14+
/// <summary>
15+
/// Disables validation for the specified endpoint.
16+
/// </summary>
17+
/// <typeparam name="TBuilder">The type of the builder.</typeparam>
18+
/// <param name="builder">The endpoint convention builder.</param>
19+
/// <returns>
20+
/// The <see cref="IEndpointConventionBuilder"/> for chaining.
21+
/// </returns>
22+
public static TBuilder DisableValidation<TBuilder>(this TBuilder builder)
23+
where TBuilder : IEndpointConventionBuilder
24+
{
25+
builder.WithMetadata(new DisableValidationMetadata());
26+
return builder;
27+
}
28+
29+
private sealed class DisableValidationMetadata : IDisableValidationMetadata
30+
{
31+
}
32+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Builder.ValidationEndpointConventionBuilderExtensions
3+
static Microsoft.AspNetCore.Builder.ValidationEndpointConventionBuilderExtensions.DisableValidation<TBuilder>(this TBuilder builder) -> TBuilder

0 commit comments

Comments
 (0)