diff --git a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Replace.cs b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Replace.cs index 88586acb..32d09571 100644 --- a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Replace.cs +++ b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Replace.cs @@ -41,8 +41,7 @@ public virtual async Task ReplaceAsync([FromRoute] string id, Can throw new HttpException(StatusCodes.Status404NotFound); } - await AuthorizeRequestAsync(TableOperation.Update, entity, cancellationToken).ConfigureAwait(false); - + await AuthorizeRequestAsync(TableOperation.Update, existing, cancellationToken).ConfigureAwait(false); if (Options.EnableSoftDelete && existing.Deleted && !Request.ShouldIncludeDeletedEntities()) { Logger.LogWarning("ReplaceAsync: {id} statusCode=410 deleted", id); @@ -50,11 +49,8 @@ public virtual async Task ReplaceAsync([FromRoute] string id, Can } Request.ParseConditionalRequest(existing, out byte[] version); - await AccessControlProvider.PreCommitHookAsync(TableOperation.Update, entity, cancellationToken).ConfigureAwait(false); - await Repository.ReplaceAsync(entity, version, cancellationToken).ConfigureAwait(false); - await PostCommitHookAsync(TableOperation.Update, entity, cancellationToken).ConfigureAwait(false); Logger.LogInformation("ReplaceAsync: replaced {entity}", entity.ToJsonString()); diff --git a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Swashbuckle_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Swashbuckle_Tests.cs index 4c4605f3..fd2592c0 100644 --- a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Swashbuckle_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Swashbuckle_Tests.cs @@ -102,6 +102,7 @@ public void GetAllTableControllers_AlternateAssembly() { // Adjust this as necessary to the list of controllers in the TestService. string[] expected = [ + "AuthorizedMovieController", "InMemoryKitchenSinkController", "InMemoryMovieController", "InMemoryPagedMovieController", diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Helpers/BaseTest.cs b/tests/CommunityToolkit.Datasync.Server.Test/Helpers/BaseTest.cs index 10fa82a4..dfb0ff9c 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Helpers/BaseTest.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Helpers/BaseTest.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using CommunityToolkit.Datasync.Common; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; using NSubstitute; diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Helpers/ServiceApplicationFactory.cs b/tests/CommunityToolkit.Datasync.Server.Test/Helpers/ServiceApplicationFactory.cs index d6e782fc..681a21d7 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Helpers/ServiceApplicationFactory.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Helpers/ServiceApplicationFactory.cs @@ -5,6 +5,7 @@ using CommunityToolkit.Datasync.Server.InMemory; using CommunityToolkit.Datasync.TestCommon.Databases; using CommunityToolkit.Datasync.TestCommon.Models; +using CommunityToolkit.Datasync.TestService.AccessControlProviders; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; @@ -26,6 +27,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) internal string MovieEndpoint = "api/in-memory/movies"; internal string PagedMovieEndpoint = "api/in-memory/pagedmovies"; internal string SoftDeletedMovieEndpoint = "api/in-memory/softmovies"; + internal string AuthorizedMovieEndpoint = "api/authorized/movies"; internal void RunWithRepository(Action> action) where TEntity : InMemoryTableData { @@ -55,6 +57,20 @@ internal TEntity GetServerEntityById(string id) where TEntity : InMemor return repository.GetEntity(id); } + internal void SetupAccessControlProvider(bool isAuthorized) + { + using IServiceScope scope = Services.CreateScope(); + IAccessControlProvider provider = scope.ServiceProvider.GetRequiredService>(); + (provider as MovieAccessControlProvider).CanBeAuthorized = isAuthorized; + } + + internal InMemoryMovie GetAuthorizedEntity() + { + using IServiceScope scope = Services.CreateScope(); + IAccessControlProvider provider = scope.ServiceProvider.GetRequiredService>(); + return (provider as MovieAccessControlProvider).LastEntity as InMemoryMovie; + } + internal void SoftDelete(TEntity entity, bool deleted = true) where TEntity : InMemoryTableData { using IServiceScope scope = Services.CreateScope(); diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Service/Replace_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Test/Service/Replace_Tests.cs index 85add3dc..df7a3575 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Service/Replace_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Service/Replace_Tests.cs @@ -5,6 +5,8 @@ using CommunityToolkit.Datasync.TestCommon; using CommunityToolkit.Datasync.TestCommon.Databases; using CommunityToolkit.Datasync.TestCommon.Models; +using CommunityToolkit.Datasync.TestService.AccessControlProviders; +using Microsoft.AspNetCore.Localization; using System.Net; using System.Net.Http.Json; using System.Text; @@ -133,4 +135,24 @@ public async Task Replace_NonJsonData_Returns415() HttpResponseMessage response = await this.client.PutAsync($"{this.factory.MovieEndpoint}/1", new StringContent(content, Encoding.UTF8, "text/html")); response.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); } + + /// + /// Given an existing entity and an access provider, ensure that we can replace the entity + /// and that the access provider is called with the existing entity, not the new entity. + /// + [Fact] + public async Task Replace_Unauthorized_Returns401() + { + // Set up the movie access control provider so that is returns false for the update operation + this.factory.SetupAccessControlProvider(false); + + InMemoryMovie inMemoryMovie = this.factory.GetRandomMovie(); + ClientMovie existingMovie = new(inMemoryMovie) { Title = "New Title" }; + HttpResponseMessage response = await this.client.PutAsJsonAsync($"{this.factory.AuthorizedMovieEndpoint}/{existingMovie.Id}", existingMovie, this.serializerOptions); + response.Should().HaveStatusCode(HttpStatusCode.Unauthorized); + + // Ensure that the access provider was called with the existing movie, not the new movie + InMemoryMovie lastEntity = this.factory.GetAuthorizedEntity(); + lastEntity.Should().HaveEquivalentMetadataTo(inMemoryMovie).And.BeEquivalentTo(inMemoryMovie); + } } diff --git a/tests/CommunityToolkit.Datasync.TestService/AccessControlProviders/MovieAccessControlProvider.cs b/tests/CommunityToolkit.Datasync.TestService/AccessControlProviders/MovieAccessControlProvider.cs new file mode 100644 index 00000000..adcad3d4 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.TestService/AccessControlProviders/MovieAccessControlProvider.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server; +using CommunityToolkit.Datasync.TestCommon.Models; + +namespace CommunityToolkit.Datasync.TestService.AccessControlProviders; + +public class MovieAccessControlProvider : AccessControlProvider where T : class, IMovie, ITableData +{ + /// + /// The last entity that was authorized. + /// + public object LastEntity { get; private set; } = null; + + /// + /// Determines if the entity can be authorized. + /// + public bool CanBeAuthorized { get; set; } = true; + + /// + public override ValueTask IsAuthorizedAsync(TableOperation operation, T entity, CancellationToken cancellationToken = default) + { + LastEntity = entity; + return ValueTask.FromResult(CanBeAuthorized); + } +} diff --git a/tests/CommunityToolkit.Datasync.TestService/Controllers/AuthorizedMovieController.cs b/tests/CommunityToolkit.Datasync.TestService/Controllers/AuthorizedMovieController.cs new file mode 100644 index 00000000..0c27f074 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.TestService/Controllers/AuthorizedMovieController.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server; +using CommunityToolkit.Datasync.TestCommon.Databases; +using Microsoft.AspNetCore.Mvc; + +namespace CommunityToolkit.Datasync.TestService.Controllers; + +[ExcludeFromCodeCoverage] +[Route("api/authorized/movies")] +public class AuthorizedMovieController : TableController +{ + public AuthorizedMovieController(IRepository repository, IAccessControlProvider provider) : base(repository) + { + AccessControlProvider = provider; + } +} diff --git a/tests/CommunityToolkit.Datasync.TestService/Program.cs b/tests/CommunityToolkit.Datasync.TestService/Program.cs index 8dad70d5..7d517f17 100644 --- a/tests/CommunityToolkit.Datasync.TestService/Program.cs +++ b/tests/CommunityToolkit.Datasync.TestService/Program.cs @@ -7,6 +7,7 @@ using TestData = CommunityToolkit.Datasync.TestCommon.TestData; using CommunityToolkit.Datasync.TestCommon.Databases; using Microsoft.OData.ModelBuilder; +using CommunityToolkit.Datasync.TestService.AccessControlProviders; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); @@ -22,6 +23,8 @@ modelBuilder.AddEntityType(typeof(InMemoryKitchenSink)); builder.Services.AddDatasyncServices(modelBuilder.GetEdmModel()); +builder.Services.AddSingleton>(new MovieAccessControlProvider()); + builder.Services.AddControllers(); WebApplication app = builder.Build();