diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs index 2ea8f89a87..596b22794d 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs @@ -166,7 +166,8 @@ private int ValidateOperation(OperationContainer operation, int operationIndex, ModelState = { MaxAllowedErrors = maxErrorsRemaining - } + }, + HttpContext = HttpContext }; ObjectValidator.Validate(validationContext, null, string.Empty, operation.Resource); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index 048ef5506f..dcd38763c9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -3,6 +3,7 @@ using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; @@ -26,6 +27,11 @@ public AtomicCreateResourceTests(IntegrationTestContext(); testContext.UseController(); + testContext.ConfigureServicesBeforeStartup(services => + { + services.AddSingleton(); + }); + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.AllowUnknownFieldsInRequestBody = false; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index 62907c047a..6e9a21d773 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -28,6 +29,7 @@ public AtomicCreateResourceWithClientGeneratedIdTests(IntegrationTestContext(); services.AddSingleton(); + services.AddSingleton(); }); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs new file mode 100644 index 0000000000..0421f2e396 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using JsonApiDotNetCore.Resources; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; + +[AttributeUsage(AttributeTargets.Property)] +internal sealed class DateMustBeInThePastAttribute : ValidationAttribute +{ + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + var targetedFields = validationContext.GetRequiredService(); + + if (targetedFields.Attributes.Any(attribute => attribute.Property.Name == validationContext.MemberName)) + { + PropertyInfo propertyInfo = validationContext.ObjectType.GetProperty(validationContext.MemberName!)!; + + if (propertyInfo.PropertyType == typeof(DateTimeOffset) || propertyInfo.PropertyType == typeof(DateTimeOffset?)) + { + var typedValue = (DateTimeOffset?)propertyInfo.GetValue(validationContext.ObjectInstance); + var systemClock = validationContext.GetRequiredService(); + + if (typedValue >= systemClock.UtcNow) + { + return new ValidationResult($"{validationContext.MemberName} must be in the past."); + } + } + } + + return ValidationResult.Success; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs index 12875231b6..2bd9dc8edf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs @@ -4,6 +4,7 @@ using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -27,6 +28,7 @@ public AtomicResourceMetaTests(IntegrationTestContext(); services.AddSingleton(); + services.AddSingleton(); }); var hitCounter = _testContext.Factory.Services.GetRequiredService(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index 0eadc45f2c..03a04fb431 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -1,7 +1,9 @@ using System.Net; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -16,6 +18,11 @@ public AtomicModelStateValidationTests(IntegrationTestContext + { + services.AddSingleton(); + }); + testContext.UseController(); } @@ -67,6 +74,51 @@ public async Task Cannot_create_resource_with_multiple_violations() error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); } + [Fact] + public async Task Cannot_create_resource_when_violation_from_custom_ValidationAttribute() + { + // Arrange + var clock = _testContext.Factory.Services.GetRequiredService(); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = "some", + lengthInSeconds = 120, + releasedAt = clock.UtcNow.AddDays(1) + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Input validation failed."); + error.Detail.Should().Be("ReleasedAt must be in the past."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/releasedAt"); + } + [Fact] public async Task Can_create_resource_with_annotated_relationship() { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs index 646a0d9ed9..52ed0ae98b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs @@ -23,6 +23,7 @@ public sealed class MusicTrack : Identifiable public string? Genre { get; set; } [Attr] + [DateMustBeInThePast] public DateTimeOffset ReleasedAt { get; set; } [HasOne] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index eb9f81b6e6..2371ab092a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -3,6 +3,7 @@ using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; @@ -29,6 +30,7 @@ public AtomicUpdateResourceTests(IntegrationTestContext(); services.AddSingleton(); + services.AddSingleton(); }); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); diff --git a/test/TestBuildingBlocks/FakerContainer.cs b/test/TestBuildingBlocks/FakerContainer.cs index 99cce6e04c..72f9a05567 100644 --- a/test/TestBuildingBlocks/FakerContainer.cs +++ b/test/TestBuildingBlocks/FakerContainer.cs @@ -12,7 +12,7 @@ static FakerContainer() { // Setting the system DateTime to kind Utc, so that faker calls like PastOffset() don't depend on the system time zone. // See https://docs.microsoft.com/en-us/dotnet/api/system.datetimeoffset.op_implicit?view=net-6.0#remarks - Date.SystemClock = () => 1.January(2020).AsUtc(); + Date.SystemClock = () => 1.January(2020).At(1, 1, 1).AsUtc(); } protected static int GetFakerSeed() diff --git a/test/TestBuildingBlocks/FrozenSystemClock.cs b/test/TestBuildingBlocks/FrozenSystemClock.cs index 0f757c63a5..a1d85e1fcc 100644 --- a/test/TestBuildingBlocks/FrozenSystemClock.cs +++ b/test/TestBuildingBlocks/FrozenSystemClock.cs @@ -5,7 +5,7 @@ namespace TestBuildingBlocks; public sealed class FrozenSystemClock : ISystemClock { - private static readonly DateTimeOffset DefaultTime = 1.January(2000).At(1, 1, 1).AsUtc(); + private static readonly DateTimeOffset DefaultTime = 1.January(2020).At(1, 1, 1).AsUtc(); public DateTimeOffset UtcNow { get; set; } = DefaultTime; }