Skip to content

Commit c6613e4

Browse files
authored
(#171) PUT operation calls authorization with existing entity (plus tests) (#179)
1 parent 5f937b8 commit c6613e4

File tree

8 files changed

+90
-6
lines changed

8 files changed

+90
-6
lines changed

src/CommunityToolkit.Datasync.Server/Controllers/TableController.Replace.cs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,20 +41,16 @@ public virtual async Task<IActionResult> ReplaceAsync([FromRoute] string id, Can
4141
throw new HttpException(StatusCodes.Status404NotFound);
4242
}
4343

44-
await AuthorizeRequestAsync(TableOperation.Update, entity, cancellationToken).ConfigureAwait(false);
45-
44+
await AuthorizeRequestAsync(TableOperation.Update, existing, cancellationToken).ConfigureAwait(false);
4645
if (Options.EnableSoftDelete && existing.Deleted && !Request.ShouldIncludeDeletedEntities())
4746
{
4847
Logger.LogWarning("ReplaceAsync: {id} statusCode=410 deleted", id);
4948
throw new HttpException(StatusCodes.Status410Gone);
5049
}
5150

5251
Request.ParseConditionalRequest(existing, out byte[] version);
53-
5452
await AccessControlProvider.PreCommitHookAsync(TableOperation.Update, entity, cancellationToken).ConfigureAwait(false);
55-
5653
await Repository.ReplaceAsync(entity, version, cancellationToken).ConfigureAwait(false);
57-
5854
await PostCommitHookAsync(TableOperation.Update, entity, cancellationToken).ConfigureAwait(false);
5955

6056
Logger.LogInformation("ReplaceAsync: replaced {entity}", entity.ToJsonString());

tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Swashbuckle_Tests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ public void GetAllTableControllers_AlternateAssembly()
102102
{
103103
// Adjust this as necessary to the list of controllers in the TestService.
104104
string[] expected = [
105+
"AuthorizedMovieController",
105106
"InMemoryKitchenSinkController",
106107
"InMemoryMovieController",
107108
"InMemoryPagedMovieController",

tests/CommunityToolkit.Datasync.Server.Test/Helpers/BaseTest.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5-
using CommunityToolkit.Datasync.Common;
65
using Microsoft.AspNetCore.Http;
76
using Microsoft.Extensions.Primitives;
87
using NSubstitute;

tests/CommunityToolkit.Datasync.Server.Test/Helpers/ServiceApplicationFactory.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using CommunityToolkit.Datasync.Server.InMemory;
66
using CommunityToolkit.Datasync.TestCommon.Databases;
77
using CommunityToolkit.Datasync.TestCommon.Models;
8+
using CommunityToolkit.Datasync.TestService.AccessControlProviders;
89
using Microsoft.AspNetCore.Hosting;
910
using Microsoft.AspNetCore.Mvc.Testing;
1011
using Microsoft.Extensions.DependencyInjection;
@@ -26,6 +27,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
2627
internal string MovieEndpoint = "api/in-memory/movies";
2728
internal string PagedMovieEndpoint = "api/in-memory/pagedmovies";
2829
internal string SoftDeletedMovieEndpoint = "api/in-memory/softmovies";
30+
internal string AuthorizedMovieEndpoint = "api/authorized/movies";
2931

3032
internal void RunWithRepository<TEntity>(Action<InMemoryRepository<TEntity>> action) where TEntity : InMemoryTableData
3133
{
@@ -55,6 +57,20 @@ internal TEntity GetServerEntityById<TEntity>(string id) where TEntity : InMemor
5557
return repository.GetEntity(id);
5658
}
5759

60+
internal void SetupAccessControlProvider(bool isAuthorized)
61+
{
62+
using IServiceScope scope = Services.CreateScope();
63+
IAccessControlProvider<InMemoryMovie> provider = scope.ServiceProvider.GetRequiredService<IAccessControlProvider<InMemoryMovie>>();
64+
(provider as MovieAccessControlProvider<InMemoryMovie>).CanBeAuthorized = isAuthorized;
65+
}
66+
67+
internal InMemoryMovie GetAuthorizedEntity()
68+
{
69+
using IServiceScope scope = Services.CreateScope();
70+
IAccessControlProvider<InMemoryMovie> provider = scope.ServiceProvider.GetRequiredService<IAccessControlProvider<InMemoryMovie>>();
71+
return (provider as MovieAccessControlProvider<InMemoryMovie>).LastEntity as InMemoryMovie;
72+
}
73+
5874
internal void SoftDelete<TEntity>(TEntity entity, bool deleted = true) where TEntity : InMemoryTableData
5975
{
6076
using IServiceScope scope = Services.CreateScope();

tests/CommunityToolkit.Datasync.Server.Test/Service/Replace_Tests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using CommunityToolkit.Datasync.TestCommon;
66
using CommunityToolkit.Datasync.TestCommon.Databases;
77
using CommunityToolkit.Datasync.TestCommon.Models;
8+
using CommunityToolkit.Datasync.TestService.AccessControlProviders;
9+
using Microsoft.AspNetCore.Localization;
810
using System.Net;
911
using System.Net.Http.Json;
1012
using System.Text;
@@ -133,4 +135,24 @@ public async Task Replace_NonJsonData_Returns415()
133135
HttpResponseMessage response = await this.client.PutAsync($"{this.factory.MovieEndpoint}/1", new StringContent(content, Encoding.UTF8, "text/html"));
134136
response.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType);
135137
}
138+
139+
/// <summary>
140+
/// Given an existing entity and an access provider, ensure that we can replace the entity
141+
/// and that the access provider is called with the existing entity, not the new entity.
142+
/// </summary>
143+
[Fact]
144+
public async Task Replace_Unauthorized_Returns401()
145+
{
146+
// Set up the movie access control provider so that is returns false for the update operation
147+
this.factory.SetupAccessControlProvider(false);
148+
149+
InMemoryMovie inMemoryMovie = this.factory.GetRandomMovie();
150+
ClientMovie existingMovie = new(inMemoryMovie) { Title = "New Title" };
151+
HttpResponseMessage response = await this.client.PutAsJsonAsync($"{this.factory.AuthorizedMovieEndpoint}/{existingMovie.Id}", existingMovie, this.serializerOptions);
152+
response.Should().HaveStatusCode(HttpStatusCode.Unauthorized);
153+
154+
// Ensure that the access provider was called with the existing movie, not the new movie
155+
InMemoryMovie lastEntity = this.factory.GetAuthorizedEntity();
156+
lastEntity.Should().HaveEquivalentMetadataTo(inMemoryMovie).And.BeEquivalentTo<IMovie>(inMemoryMovie);
157+
}
136158
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.Server;
6+
using CommunityToolkit.Datasync.TestCommon.Models;
7+
8+
namespace CommunityToolkit.Datasync.TestService.AccessControlProviders;
9+
10+
public class MovieAccessControlProvider<T> : AccessControlProvider<T> where T : class, IMovie, ITableData
11+
{
12+
/// <summary>
13+
/// The last entity that was authorized.
14+
/// </summary>
15+
public object LastEntity { get; private set; } = null;
16+
17+
/// <summary>
18+
/// Determines if the entity can be authorized.
19+
/// </summary>
20+
public bool CanBeAuthorized { get; set; } = true;
21+
22+
/// <inheritdoc />
23+
public override ValueTask<bool> IsAuthorizedAsync(TableOperation operation, T entity, CancellationToken cancellationToken = default)
24+
{
25+
LastEntity = entity;
26+
return ValueTask.FromResult(CanBeAuthorized);
27+
}
28+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.Server;
6+
using CommunityToolkit.Datasync.TestCommon.Databases;
7+
using Microsoft.AspNetCore.Mvc;
8+
9+
namespace CommunityToolkit.Datasync.TestService.Controllers;
10+
11+
[ExcludeFromCodeCoverage]
12+
[Route("api/authorized/movies")]
13+
public class AuthorizedMovieController : TableController<InMemoryMovie>
14+
{
15+
public AuthorizedMovieController(IRepository<InMemoryMovie> repository, IAccessControlProvider<InMemoryMovie> provider) : base(repository)
16+
{
17+
AccessControlProvider = provider;
18+
}
19+
}

tests/CommunityToolkit.Datasync.TestService/Program.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using TestData = CommunityToolkit.Datasync.TestCommon.TestData;
88
using CommunityToolkit.Datasync.TestCommon.Databases;
99
using Microsoft.OData.ModelBuilder;
10+
using CommunityToolkit.Datasync.TestService.AccessControlProviders;
1011

1112
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
1213

@@ -22,6 +23,8 @@
2223
modelBuilder.AddEntityType(typeof(InMemoryKitchenSink));
2324
builder.Services.AddDatasyncServices(modelBuilder.GetEdmModel());
2425

26+
builder.Services.AddSingleton<IAccessControlProvider<InMemoryMovie>>(new MovieAccessControlProvider<InMemoryMovie>());
27+
2528
builder.Services.AddControllers();
2629

2730
WebApplication app = builder.Build();

0 commit comments

Comments
 (0)