From eacfc8375aef904c1a37a696acfdea8826b2033e Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 8 Nov 2021 11:51:20 +0100 Subject: [PATCH] Explores support for concurrency tokens using PostgreSQL *Update (2021-11-08): Rebased on latest changes in master branch* Because we fetch the row before update and apply changes on that, a concurrency violation is only reported when two concurrent requests update the same row in parallel. Instead, we want to produce an error if the token sent by the user does not match the stored token. To do that, we need to convince EF Core to use that as original version. That's not too hard. Now the problem is that there is no way to send the token for relationships or deleting a resource. Skipped tests have been added to demonstrate this. We could fetch such related rows upfront to work around that, but that kinda defeats the purpose of using concurrency tokens in the first place. It may be more correct to fail when a user is trying to add a related resource that has changed since it was fetched. This reasoning may be a bit too puristic and impractical, but at least that's how EF Core seems to handle it. Solutions considerations: - Add 'version' to resource identifier object, so the client can send it. The spec does not explicitly forbid adding custom fields, however 'meta' would probably be the recommended approach. Instead of extending the definition, we could encode it in the StringId. - Once we have access to that token value, we need to somehow map that to 'the' resource property. What if there are multiple concurrency token properties on a resource? And depending on the database used, this could be typed as numeric, guid, timestamp, binary or something else. - Given that PostgreSQL uses a number (uint xmin), should we obfuscate or even encrypt that? If the latter, we need to add an option for api developers to set the encryption key. See also: https://github.com/json-api/json-api/issues/600 https://github.com/json-api/json-api/pull/824 --- JsonApiDotNetCore.sln.DotSettings | 1 + .../Errors/DataConcurrencyException.cs | 23 + .../EntityFrameworkCoreRepository.cs | 48 +- .../ConcurrencyTokens/ConcurrencyDbContext.cs | 30 + .../ConcurrencyTokens/ConcurrencyFakers.cs | 31 + .../ConcurrencyTokenTests.cs | 643 ++++++++++++++++++ .../ConcurrencyTokens/Disk.cs | 27 + .../ConcurrencyTokens/DisksController.cs | 16 + .../ConcurrencyTokens/Partition.cs | 29 + .../ConcurrencyTokens/PartitionsController.cs | 16 + 10 files changed, 857 insertions(+), 7 deletions(-) create mode 100644 src/JsonApiDotNetCore/Errors/DataConcurrencyException.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/ConcurrencyDbContext.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/ConcurrencyFakers.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/ConcurrencyTokenTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/Disk.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/DisksController.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/Partition.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/PartitionsController.cs diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 595cae92d8..bd2a8fe2d1 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -637,5 +637,6 @@ $left$ = $right$; True True True + True True diff --git a/src/JsonApiDotNetCore/Errors/DataConcurrencyException.cs b/src/JsonApiDotNetCore/Errors/DataConcurrencyException.cs new file mode 100644 index 0000000000..82baface2e --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/DataConcurrencyException.cs @@ -0,0 +1,23 @@ +using System; +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when data has been modified on the server since the resource was retrieved. + /// + [PublicAPI] + public sealed class DataConcurrencyException : JsonApiException + { + public DataConcurrencyException(Exception exception) + : base(new ErrorObject(HttpStatusCode.Conflict) + { + Title = "The concurrency token is missing or does not match the server version. " + + "This indicates that data has been modified since the resource was retrieved." + }, exception) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 2092f8935a..61dc6d1660 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Linq.Expressions; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; @@ -207,7 +208,7 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r DbSet dbSet = _dbContext.Set(); await dbSet.AddAsync(resourceForDatabase, cancellationToken); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken, false); await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); @@ -283,15 +284,43 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); } + bool hasConcurrencyToken = RestoreConcurrencyToken(resourceFromRequest, resourceFromDatabase); + await _resourceDefinitionAccessor.OnWritingAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken, hasConcurrencyToken); await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); _dbContext.ResetChangeTracker(); } + private bool RestoreConcurrencyToken(TResource resourceFromRequest, TResource resourceFromDatabase) + { + bool hasConcurrencyToken = false; + + foreach (var propertyEntry in _dbContext.Entry(resourceFromDatabase).Properties) + { + if (propertyEntry.Metadata.IsConcurrencyToken) + { + // Overwrite the ConcurrencyToken coming from database with the one from the request body. + // If they are different, EF Core throws a DbUpdateConcurrencyException on save. + + PropertyInfo? concurrencyTokenProperty = typeof(TResource).GetProperty(propertyEntry.Metadata.PropertyInfo.Name); + + if (concurrencyTokenProperty != null) + { + object? concurrencyTokenFromRequest = concurrencyTokenProperty.GetValue(resourceFromRequest); + propertyEntry.OriginalValue = concurrencyTokenFromRequest; + + hasConcurrencyToken = true; + } + } + } + + return hasConcurrencyToken; + } + protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribute relationship, TResource leftResource, object? rightValue) { if (relationship is HasOneAttribute) @@ -341,7 +370,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke _dbContext.Remove(resourceTracked); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken, false); await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceTracked, WriteOperationKind.DeleteResource, cancellationToken); } @@ -412,7 +441,7 @@ public virtual async Task SetRelationshipAsync(TResource leftResource, object? r await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken, false); await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); } @@ -445,7 +474,7 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, ISet Disks => Set(); + public DbSet Partitions => Set(); + + public ConcurrencyDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + // https://www.npgsql.org/efcore/modeling/concurrency.html + + builder.Entity() + .UseXminAsConcurrencyToken(); + + builder.Entity() + .UseXminAsConcurrencyToken(); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/ConcurrencyFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/ConcurrencyFakers.cs new file mode 100644 index 0000000000..ad526c475f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/ConcurrencyFakers.cs @@ -0,0 +1,31 @@ +using System; +using Bogus; +using JsonApiDotNetCore; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens +{ + internal sealed class ConcurrencyFakers : FakerContainer + { + private const ulong OneGigabyte = 1024 * 1024 * 1024; + private static readonly string[] KnownFileSystems = ArrayFactory.Create("NTFS", "FAT32", "ext4", "XFS", "ZFS", "btrfs"); + + private readonly Lazy> _lazyDiskFaker = new(() => + new Faker().UseSeed(GetFakerSeed()) + .RuleFor(disk => disk.Manufacturer, faker => faker.Company.CompanyName()) + .RuleFor(disk => disk.SerialCode, faker => faker.System.ApplePushToken())); + + private readonly Lazy> _lazyPartitionFaker = new(() => + new Faker().UseSeed(GetFakerSeed()) + .RuleFor(partition => partition.MountPoint, faker => faker.System.DirectoryPath()) + .RuleFor(partition => partition.FileSystem, faker => faker.PickRandom(KnownFileSystems)) + .RuleFor(partition => partition.CapacityInBytes, faker => faker.Random.ULong(OneGigabyte * 50, OneGigabyte * 100)) + .RuleFor(partition => partition.FreeSpaceInBytes, faker => faker.Random.ULong(OneGigabyte * 10, OneGigabyte * 40))); + + public Faker Disk => _lazyDiskFaker.Value; + public Faker Partition => _lazyPartitionFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/ConcurrencyTokenTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/ConcurrencyTokenTests.cs new file mode 100644 index 0000000000..efd254fbf6 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/ConcurrencyTokenTests.cs @@ -0,0 +1,643 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens +{ + public sealed class ConcurrencyTokenTests : IClassFixture, ConcurrencyDbContext>> + { + private readonly IntegrationTestContext, ConcurrencyDbContext> _testContext; + private readonly ConcurrencyFakers _fakers = new(); + + public ConcurrencyTokenTests(IntegrationTestContext, ConcurrencyDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + } + + [Fact] + public async Task Can_get_primary_resource_by_ID_with_include() + { + // Arrange + Disk disk = _fakers.Disk.Generate(); + disk.Partitions = _fakers.Partition.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Disks.Add(disk); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/disks/{disk.StringId}?include=partitions"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("disks"); + responseDocument.Data.SingleValue.Id.Should().Be(disk.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("manufacturer").With(value => value.Should().Be(disk.Manufacturer)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("concurrencyToken").With(value => value.Should().Be(disk.xmin)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Type.Should().Be("partitions"); + responseDocument.Included[0].Id.Should().Be(disk.Partitions[0].StringId); + responseDocument.Included[0].Attributes.ShouldContainKey("mountPoint").With(value => value.Should().Be(disk.Partitions[0].MountPoint)); + responseDocument.Included[0].Attributes.ShouldContainKey("fileSystem").With(value => value.Should().Be(disk.Partitions[0].FileSystem)); + responseDocument.Included[0].Attributes.ShouldContainKey("capacityInBytes").With(value => value.Should().Be(disk.Partitions[0].CapacityInBytes)); + responseDocument.Included[0].Attributes.ShouldContainKey("freeSpaceInBytes").With(value => value.Should().Be(disk.Partitions[0].FreeSpaceInBytes)); + responseDocument.Included[0].Attributes.ShouldContainKey("concurrencyToken").With(value => value.Should().Be(disk.Partitions[0].xmin)); + responseDocument.Included[0].Relationships.ShouldNotBeEmpty(); + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + string newManufacturer = _fakers.Disk.Generate().Manufacturer; + string newSerialCode = _fakers.Disk.Generate().SerialCode; + + var requestBody = new + { + data = new + { + type = "disks", + attributes = new + { + manufacturer = newManufacturer, + serialCode = newSerialCode + } + } + }; + + const string route = "/disks"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("disks"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("manufacturer").With(value => value.Should().Be(newManufacturer)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("serialCode").With(value => value.Should().Be(newSerialCode)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("concurrencyToken").With(value => value.As().Should().BeGreaterThan(0)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + + long newDiskId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.FirstWithIdAsync(newDiskId); + + diskInDatabase.Manufacturer.Should().Be(newManufacturer); + diskInDatabase.SerialCode.Should().Be(newSerialCode); + diskInDatabase.xmin.Should().BeGreaterThan(0); + }); + } + + [Fact] + public async Task Can_create_resource_with_ignored_token() + { + // Arrange + string newManufacturer = _fakers.Disk.Generate().Manufacturer; + string newSerialCode = _fakers.Disk.Generate().SerialCode; + const uint ignoredToken = 98765432; + + var requestBody = new + { + data = new + { + type = "disks", + attributes = new + { + manufacturer = newManufacturer, + serialCode = newSerialCode, + concurrencyToken = ignoredToken + } + } + }; + + const string route = "/disks"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("disks"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("manufacturer").With(value => value.Should().Be(newManufacturer)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("serialCode").With(value => value.Should().Be(newSerialCode)); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("concurrencyToken").With(value => + { + long typedValue = value.As(); + typedValue.Should().BeGreaterThan(0); + typedValue.Should().NotBe(ignoredToken); + }); + + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + + long newDiskId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.FirstWithIdAsync(newDiskId); + + diskInDatabase.Manufacturer.Should().Be(newManufacturer); + diskInDatabase.SerialCode.Should().Be(newSerialCode); + diskInDatabase.xmin.Should().BeGreaterThan(0).And.NotBe(ignoredToken); + }); + } + + [Fact(Skip = "There is no way to send the token, which is needed to find the related resource.")] + public async Task Can_create_resource_with_relationship() + { + // Arrange + Partition existingPartition = _fakers.Partition.Generate(); + + string newManufacturer = _fakers.Disk.Generate().Manufacturer; + string newSerialCode = _fakers.Disk.Generate().SerialCode; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Partitions.Add(existingPartition); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "disks", + attributes = new + { + manufacturer = newManufacturer, + serialCode = newSerialCode + }, + relationships = new + { + partitions = new + { + data = new[] + { + new + { + type = "partitions", + id = existingPartition.StringId + } + } + } + } + } + }; + + const string route = "/disks"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("disks"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("manufacturer").With(value => value.Should().Be(newManufacturer)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("serialCode").With(value => value.Should().Be(newSerialCode)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("concurrencyToken").With(value => value.As().Should().BeGreaterThan(0)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + + long newDiskId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.Include(disk => disk.Partitions).FirstWithIdAsync(newDiskId); + + diskInDatabase.Manufacturer.Should().Be(newManufacturer); + diskInDatabase.SerialCode.Should().Be(newSerialCode); + diskInDatabase.xmin.Should().BeGreaterThan(0); + + diskInDatabase.Partitions.ShouldHaveCount(1); + diskInDatabase.Partitions[0].Id.Should().Be(existingPartition.Id); + }); + } + + [Fact] + public async Task Can_update_resource_using_fresh_token() + { + // Arrange + Disk existingDisk = _fakers.Disk.Generate(); + + string newSerialCode = _fakers.Disk.Generate().SerialCode; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Disks.Add(existingDisk); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "disks", + id = existingDisk.StringId, + attributes = new + { + serialCode = newSerialCode, + concurrencyToken = existingDisk.xmin + } + } + }; + + string route = "/disks/" + existingDisk.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("disks"); + responseDocument.Data.SingleValue.Id.Should().Be(existingDisk.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("manufacturer").With(value => value.Should().Be(existingDisk.Manufacturer)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("serialCode").With(value => value.Should().Be(newSerialCode)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("concurrencyToken").With(value => value.Should().NotBe(existingDisk.xmin)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.FirstWithIdAsync(existingDisk.Id); + + diskInDatabase.Manufacturer.Should().Be(existingDisk.Manufacturer); + diskInDatabase.SerialCode.Should().Be(newSerialCode); + diskInDatabase.xmin.Should().NotBe(existingDisk.xmin); + }); + } + + [Fact] + public async Task Cannot_update_resource_using_stale_token() + { + // Arrange + Disk existingDisk = _fakers.Disk.Generate(); + + string newSerialCode = _fakers.Disk.Generate().SerialCode; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Disks.Add(existingDisk); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"Disks\" set \"Manufacturer\" = 'other' where \"Id\" = {existingDisk.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "disks", + id = existingDisk.StringId, + attributes = new + { + serialCode = newSerialCode, + concurrencyToken = existingDisk.xmin + } + } + }; + + string route = "/disks/" + existingDisk.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The concurrency token is missing or does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_resource_without_token() + { + // Arrange + Disk existingDisk = _fakers.Disk.Generate(); + + string newSerialCode = _fakers.Disk.Generate().SerialCode; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Disks.Add(existingDisk); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "disks", + id = existingDisk.StringId, + attributes = new + { + serialCode = newSerialCode + } + } + }; + + string route = "/disks/" + existingDisk.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The concurrency token is missing or does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_update_resource_with_HasOne_relationship() + { + // Arrange + Partition existingPartition = _fakers.Partition.Generate(); + existingPartition.Owner = _fakers.Disk.Generate(); + + Disk existingDisk = _fakers.Disk.Generate(); + + ulong? newFreeSpaceInBytes = _fakers.Partition.Generate().FreeSpaceInBytes; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPartition, existingDisk); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "partitions", + id = existingPartition.StringId, + attributes = new + { + freeSpaceInBytes = newFreeSpaceInBytes, + concurrencyToken = existingPartition.xmin + }, + relationships = new + { + owner = new + { + data = new + { + type = "disks", + id = existingDisk.StringId + } + } + } + } + }; + + string route = "/partitions/" + existingPartition.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + ulong? capacityInBytes = existingPartition.CapacityInBytes; + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("partitions"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("capacityInBytes").With(value => value.Should().Be(capacityInBytes)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("freeSpaceInBytes").With(value => value.Should().Be(newFreeSpaceInBytes)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("concurrencyToken").With(value => value.As().Should().BeGreaterThan(0)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Partition partitionInDatabase = await dbContext.Partitions.Include(partition => partition.Owner).FirstWithIdAsync(existingPartition.Id); + + partitionInDatabase.CapacityInBytes.Should().Be(capacityInBytes); + partitionInDatabase.FreeSpaceInBytes.Should().Be(newFreeSpaceInBytes); + partitionInDatabase.xmin.Should().BeGreaterThan(0); + + partitionInDatabase.Owner.ShouldNotBeNull(); + partitionInDatabase.Owner.Id.Should().Be(existingDisk.Id); + }); + } + + [Fact(Skip = "There is no way to send the token, which is needed to find the related resource.")] + public async Task Can_update_resource_with_HasMany_relationship() + { + // Arrange + Disk existingDisk = _fakers.Disk.Generate(); + existingDisk.Partitions = _fakers.Partition.Generate(1); + + Partition existingPartition = _fakers.Partition.Generate(); + + string newSerialCode = _fakers.Disk.Generate().SerialCode; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingDisk, existingPartition); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "disks", + id = existingDisk.StringId, + attributes = new + { + serialCode = newSerialCode, + concurrencyToken = existingDisk.xmin + }, + relationships = new + { + partitions = new + { + data = new[] + { + new + { + type = "partitions", + id = existingPartition.StringId + } + } + } + } + } + }; + + string route = "/disks/" + existingDisk.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("disks"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("manufacturer").With(value => value.Should().Be(existingDisk.Manufacturer)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("serialCode").With(value => value.Should().Be(newSerialCode)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("concurrencyToken").With(value => value.As().Should().BeGreaterThan(0)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.Include(disk => disk.Partitions).FirstWithIdAsync(existingDisk.Id); + + diskInDatabase.Manufacturer.Should().Be(existingDisk.Manufacturer); + diskInDatabase.SerialCode.Should().Be(newSerialCode); + diskInDatabase.xmin.Should().BeGreaterThan(0); + + diskInDatabase.Partitions.ShouldHaveCount(1); + diskInDatabase.Partitions[0].Id.Should().Be(existingPartition.Id); + }); + } + + [Fact(Skip = "There is no way to send the token, which is needed to find the resource.")] + public async Task Can_delete_resource() + { + // Arrange + Disk existingDisk = _fakers.Disk.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Disks.Add(existingDisk); + await dbContext.SaveChangesAsync(); + }); + + string route = "/disks/" + existingDisk.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.FirstOrDefaultAsync(disk => disk.Id == existingDisk.Id); + + diskInDatabase.Should().BeNull(); + }); + } + + [Fact(Skip = "There is no way to send the token, which is needed to find the related resource.")] + public async Task Can_add_to_HasMany_relationship() + { + // Arrange + Disk existingDisk = _fakers.Disk.Generate(); + Partition existingPartition = _fakers.Partition.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingDisk, existingPartition); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "partitions", + id = existingPartition.StringId + } + } + }; + + string route = $"/disks/{existingDisk.StringId}/relationships/partitions"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.Include(disk => disk.Partitions).FirstWithIdAsync(existingDisk.Id); + + diskInDatabase.Partitions.ShouldHaveCount(1); + diskInDatabase.Partitions[0].Id.Should().Be(existingPartition.Id); + }); + } + + [Fact(Skip = "There is no way to send the token, which is needed to find the related resource.")] + public async Task Can_remove_from_HasMany_relationship() + { + // Arrange + Disk existingDisk = _fakers.Disk.Generate(); + existingDisk.Partitions = _fakers.Partition.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Disks.Add(existingDisk); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "partitions", + id = existingDisk.Partitions[1].StringId + } + } + }; + + string route = $"/disks/{existingDisk.StringId}/relationships/partitions"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.Include(disk => disk.Partitions).FirstWithIdAsync(existingDisk.Id); + + diskInDatabase.Partitions.ShouldHaveCount(1); + diskInDatabase.Partitions[0].Id.Should().Be(existingDisk.Partitions[0].Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/Disk.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/Disk.cs new file mode 100644 index 0000000000..f26838bcf8 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/Disk.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Disk : Identifiable + { + [Attr] + public string Manufacturer { get; set; } = null!; + + [Attr] + public string SerialCode { get; set; } = null!; + + [ConcurrencyCheck] + [Timestamp] + [Attr(PublicName = "concurrencyToken")] + // ReSharper disable once InconsistentNaming + public uint xmin { get; set; } + + [HasMany] + public IList Partitions { get; set; } = new List(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/DisksController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/DisksController.cs new file mode 100644 index 0000000000..3ce26063ab --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/DisksController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens +{ + public sealed class DisksController : JsonApiController + { + public DisksController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/Partition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/Partition.cs new file mode 100644 index 0000000000..cc3428ad97 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/Partition.cs @@ -0,0 +1,29 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Partition : Identifiable + { + [Attr] + public string MountPoint { get; set; } = null!; + + [Attr] + public string FileSystem { get; set; } = null!; + + [Attr] + public ulong CapacityInBytes { get; set; } + + [Attr] + public ulong FreeSpaceInBytes { get; set; } + + [Attr(PublicName = "concurrencyToken")] + // ReSharper disable once InconsistentNaming + public uint xmin { get; set; } + + [HasOne] + public Disk Owner { get; set; } = null!; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/PartitionsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/PartitionsController.cs new file mode 100644 index 0000000000..858d036dcf --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/PartitionsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens +{ + public sealed class PartitionsController : JsonApiController + { + public PartitionsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +}