Browse Source

Add opt-in options for completing the unit of work on response start

- Enable globally via CompleteUnitOfWorkOnResponseStarting or per path via the Urls list
- OpenIddict opts in its endpoints so token/session rows commit before the response
pull/26017/head
maliming 1 day ago
parent
commit
eaf1bf6fe3
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 1
      framework/Volo.Abp.slnx
  2. 27
      framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUnitOfWorkOptions.cs
  3. 50
      framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpUnitOfWorkMiddleware.cs
  4. 82
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs
  5. 14
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs
  6. 26
      framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo.Abp.AspNetCore.Uow.Tests.csproj
  7. 61
      framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUowTestModule.cs
  8. 15
      framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/Program.cs
  9. 120
      framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UnitOfWorkMiddleware_Relational_Tests.cs
  10. 107
      framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UowVisibilityController.cs
  11. 46
      framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UowVisibilityTestEntity.cs
  12. 40
      modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/AbpOpenIddictAspNetCoreModule.cs
  13. 6
      modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo.Abp.OpenIddict.AspNetCore.Tests.csproj
  14. 58
      modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenEndpoint_Integration_Tests.cs
  15. 150
      modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenIntegrationTestModule.cs
  16. 15
      modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/Program.cs

1
framework/Volo.Abp.slnx

@ -194,6 +194,7 @@
<Project Path="test/Volo.Abp.AspNetCore.Serilog.Tests/Volo.Abp.AspNetCore.Serilog.Tests.csproj" />
<Project Path="test/Volo.Abp.AspNetCore.SignalR.Tests/Volo.Abp.AspNetCore.SignalR.Tests.csproj" />
<Project Path="test/Volo.Abp.AspNetCore.Tests/Volo.Abp.AspNetCore.Tests.csproj" />
<Project Path="test/Volo.Abp.AspNetCore.Uow.Tests/Volo.Abp.AspNetCore.Uow.Tests.csproj" />
<Project Path="test/Volo.Abp.Auditing.Tests/Volo.Abp.Auditing.Tests.csproj" />
<Project Path="test/Volo.Abp.Authorization.Tests/Volo.Abp.Authorization.Tests.csproj" />
<Project Path="test/Volo.Abp.Autofac.Tests/Volo.Abp.Autofac.Tests.csproj" />

27
framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUnitOfWorkOptions.cs

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
namespace Volo.Abp.AspNetCore.Uow;
@ -11,4 +11,29 @@ public class AbpAspNetCoreUnitOfWorkOptions
/// starting with an ignored URL.
/// </summary>
public List<string> IgnoredUrls { get; } = new List<string>();
/// <summary>
/// Completes the request unit of work just before the response starts (on
/// <c>HttpResponse.OnStarting</c>) instead of at the end of the pipeline, so data written during
/// the request is committed before the response is flushed. Disabled by default; enable it here
/// globally or opt-in per endpoint via <see cref="CompleteUnitOfWorkOnResponseStartingUrls"/>.
/// <para>
/// Trade-offs when it applies: an exception after the response starts can no longer roll back the
/// committed data (commit and network response are not atomic); database access after the response
/// starts is outside the request unit of work (unsuitable for streaming responses); unit of work
/// events and completed handlers run before the first response byte (adding to its latency); a
/// nested (requiresNew) unit of work that is current when the response starts is left to its owner
/// and the request unit of work then completes at the end of the pipeline as usual.
/// </para>
/// </summary>
public bool CompleteUnitOfWorkOnResponseStarting { get; set; } = false;
/// <summary>
/// Absolute request path prefixes (matched by segment) that opt-in to
/// <see cref="CompleteUnitOfWorkOnResponseStarting"/> even when it is globally disabled (for example
/// "/connect" matches "/connect/token" but not "/connections"). A trailing slash is normalized; blank,
/// non-absolute, and root ("/") entries are ignored - use <see cref="CompleteUnitOfWorkOnResponseStarting"/>
/// to enable it for every request.
/// </summary>
public List<string> CompleteUnitOfWorkOnResponseStartingUrls { get; } = new List<string>();
}

50
framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpUnitOfWorkMiddleware.cs

@ -37,21 +37,25 @@ public class AbpUnitOfWorkMiddleware : AbpMiddlewareBase, ITransientDependency
using (var uow = _unitOfWorkManager.Reserve(UnitOfWork.UnitOfWorkReservationName))
{
// Commit the ambient unit of work before the response starts, so data written
// during the request is committed before the response is flushed to the client.
// Only when this reserved unit of work is the current one: if an explicit nested
// unit of work is in progress, it is the current one and must be left to its owner.
context.Response.OnStarting(async () =>
var completedOnResponseStarting = false;
if (!context.Response.HasStarted && ShouldCompleteOnResponseStarting(context))
{
if (_unitOfWorkManager.Current == uow)
context.Response.OnStarting(async () =>
{
await uow.CompleteAsync(_cancellationTokenProvider.Token);
}
});
// A nested (requiresNew) unit of work that is current is left to its owner.
if (_unitOfWorkManager.Current == uow)
{
// Set before completing so a post-commit failure isn't masked by the completion below.
completedOnResponseStarting = true;
await uow.CompleteAsync(_cancellationTokenProvider.Token);
}
});
}
await next(context);
if (!uow.IsCompleted)
if (!completedOnResponseStarting)
{
await uow.CompleteAsync(_cancellationTokenProvider.Token);
}
@ -64,6 +68,32 @@ public class AbpUnitOfWorkMiddleware : AbpMiddlewareBase, ITransientDependency
_options.IgnoredUrls.Any(x => context.Request.Path.Value.StartsWith(x, StringComparison.OrdinalIgnoreCase));
}
private bool ShouldCompleteOnResponseStarting(HttpContext context)
{
if (_options.CompleteUnitOfWorkOnResponseStarting)
{
return true;
}
foreach (var url in _options.CompleteUnitOfWorkOnResponseStartingUrls)
{
if (string.IsNullOrWhiteSpace(url))
{
continue;
}
// Normalize a trailing slash ("/connect/" behaves like "/connect") and ignore non-absolute entries.
var prefix = url.TrimEnd('/');
if (prefix.StartsWith("/", StringComparison.Ordinal) &&
context.Request.Path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
protected async override Task<bool> ShouldSkipAsync(HttpContext context, RequestDelegate next)
{
// Blazor components will render concurrently, so we need to skip the middleware for them.

82
framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs

@ -1,14 +1,19 @@
using System.Net;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Shouldly;
using Volo.Abp.AspNetCore.Uow;
using Xunit;
namespace Volo.Abp.AspNetCore.Mvc.Uow;
public class UnitOfWorkMiddleware_Tests : AspNetCoreMvcTestBase
{
private AbpAspNetCoreUnitOfWorkOptions Options =>
ServiceProvider.GetRequiredService<IOptions<AbpAspNetCoreUnitOfWorkOptions>>().Value;
[Fact]
public async Task Get_Actions_Should_Not_Be_Transactional()
{
@ -31,17 +36,28 @@ public class UnitOfWorkMiddleware_Tests : AspNetCoreMvcTestBase
}
[Fact]
public async Task Ambient_Uow_Should_Be_Completed_Before_Response_Is_Flushed()
public async Task Ambient_Uow_Should_Be_Completed_Before_Response_Is_Flushed_When_Enabled()
{
Options.CompleteUnitOfWorkOnResponseStarting = true;
var result = await GetResponseAsStringAsync("/api/unitofwork-test/CommitBeforeResponseFlush");
result.ShouldBe("first:completed");
}
[Fact]
public async Task Exception_After_Response_Flush_Should_Not_Undo_Committed_Work()
public async Task Ambient_Uow_Is_Not_Completed_On_Response_Start_By_Default()
{
// Once the response has started, an exception can't turn it into an error response
// (the connection is reset). What matters: the uow was committed before the throw.
var result = await GetResponseAsStringAsync("/api/unitofwork-test/CommitBeforeResponseFlush");
result.ShouldBe("first:not-completed");
}
[Fact]
public async Task Ambient_Uow_Is_Already_Completed_When_An_Exception_Is_Raised_After_The_Response_Started()
{
Options.CompleteUnitOfWorkOnResponseStarting = true;
// Once the response has started, an exception can't turn it into an error response (the
// connection is reset). Database-level rollback/commit is covered by the relational tests.
await Should.ThrowAsync<HttpRequestException>(async () =>
{
var response = await Client.GetAsync("/api/unitofwork-test/CommitThenThrowAfterResponseFlush");
@ -55,8 +71,8 @@ public class UnitOfWorkMiddleware_Tests : AspNetCoreMvcTestBase
[Fact]
public async Task Repository_Access_After_Response_Flush_Runs_Outside_The_Request_Uow()
{
// After the response starts the request uow is gone; a repository still works via its
// own implicit uow (ambient=null), so it no longer joins the request transaction.
Options.CompleteUnitOfWorkOnResponseStarting = true;
var body = await GetResponseAsStringAsync("/api/unitofwork-test/ReadRepositoryAfterResponseFlush");
body.ShouldBe("before=ok(1);after=ok(1,ambient=null)");
}
@ -64,7 +80,8 @@ public class UnitOfWorkMiddleware_Tests : AspNetCoreMvcTestBase
[Fact]
public async Task Raw_Database_Provider_After_Response_Flush_Throws()
{
// Unlike repositories, raw provider access after the response started has no uow and throws.
Options.CompleteUnitOfWorkOnResponseStarting = true;
var body = await GetResponseAsStringAsync("/api/unitofwork-test/RawDatabaseProviderAfterResponseFlush");
body.ShouldBe("first:threw-AbpException");
}
@ -72,7 +89,54 @@ public class UnitOfWorkMiddleware_Tests : AspNetCoreMvcTestBase
[Fact]
public async Task Response_Flush_Inside_Nested_Uow_Should_Not_Complete_The_Nested_Uow()
{
Options.CompleteUnitOfWorkOnResponseStarting = true;
var body = await GetResponseAsStringAsync("/api/unitofwork-test/NestedUowDuringResponseFlush");
body.ShouldBe("first:nested-completed-by-owner");
body.ShouldBe("first:outer-not-completed:nested-completed-by-owner");
}
[Fact]
public async Task Completing_The_Uow_In_The_Action_Still_Fails_At_End_Of_Pipeline_By_Default()
{
var response = await Client.GetAsync("/api/unitofwork-test/CompleteCurrentUow");
response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError);
}
[Fact]
public async Task Opt_In_Url_Enables_The_Feature_For_A_Matching_Path()
{
Options.CompleteUnitOfWorkOnResponseStartingUrls.Add("/api/unitofwork-test/CommitBeforeResponseFlush");
var result = await GetResponseAsStringAsync("/api/unitofwork-test/CommitBeforeResponseFlush");
result.ShouldBe("first:completed");
}
[Fact]
public async Task Opt_In_Url_With_A_Trailing_Slash_Still_Matches()
{
Options.CompleteUnitOfWorkOnResponseStartingUrls.Add("/api/unitofwork-test/");
var result = await GetResponseAsStringAsync("/api/unitofwork-test/CommitBeforeResponseFlush");
result.ShouldBe("first:completed");
}
[Fact]
public async Task Opt_In_Url_With_A_Non_Segment_Prefix_Should_Not_Match()
{
Options.CompleteUnitOfWorkOnResponseStartingUrls.Add("/api/unitofwork-test/Commit");
var result = await GetResponseAsStringAsync("/api/unitofwork-test/CommitBeforeResponseFlush");
result.ShouldBe("first:not-completed");
}
[Fact]
public async Task Blank_Or_Malformed_Opt_In_Urls_Are_Ignored()
{
Options.CompleteUnitOfWorkOnResponseStartingUrls.Add("");
Options.CompleteUnitOfWorkOnResponseStartingUrls.Add(" ");
Options.CompleteUnitOfWorkOnResponseStartingUrls.Add("api/no-leading-slash");
var result = await GetResponseAsStringAsync("/api/unitofwork-test/CommitBeforeResponseFlush");
result.ShouldBe("first:not-completed");
}
}

14
framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs

@ -97,7 +97,6 @@ public class UnitOfWorkTestController : AbpController
await Response.WriteAsync("first");
await Response.Body.FlushAsync();
// Record the commit state so the test can assert the throw below doesn't undo it.
_testUnitOfWorkConfig.UowCompletedAfterResponseFlush = uow.IsCompleted;
throw new UserFriendlyException("boom after the response was already flushed");
@ -161,6 +160,10 @@ public class UnitOfWorkTestController : AbpController
await Response.WriteAsync("first");
await Response.Body.FlushAsync();
// The outer request unit of work (nested.Outer) must not have been completed on response
// start while a nested unit of work is current.
await Response.WriteAsync(nested.Outer!.IsCompleted ? ":outer-completed" : ":outer-not-completed");
string outcome;
try
{
@ -175,4 +178,13 @@ public class UnitOfWorkTestController : AbpController
await Response.WriteAsync(outcome);
}
}
[HttpGet]
[Route("CompleteCurrentUow")]
public async Task CompleteCurrentUow()
{
// Complete the request unit of work inside the action, without writing the response yet.
// The middleware must still try to complete it at the end of the pipeline (original behavior).
await CurrentUnitOfWork.CompleteAsync();
}
}

26
framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo.Abp.AspNetCore.Uow.Tests.csproj

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\common.test.props" />
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>Volo.Abp.AspNetCore.Uow.Tests</AssemblyName>
<PackageId>Volo.Abp.AspNetCore.Uow.Tests</PackageId>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
<PreserveCompilationReferences>true</PreserveCompilationReferences>
<RootNamespace />
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Volo.Abp.AspNetCore.Mvc\Volo.Abp.AspNetCore.Mvc.csproj" />
<ProjectReference Include="..\..\src\Volo.Abp.AspNetCore.TestBase\Volo.Abp.AspNetCore.TestBase.csproj" />
<ProjectReference Include="..\..\src\Volo.Abp.EntityFrameworkCore.Sqlite\Volo.Abp.EntityFrameworkCore.Sqlite.csproj" />
<ProjectReference Include="..\..\src\Volo.Abp.Autofac\Volo.Abp.Autofac.csproj" />
<ProjectReference Include="..\AbpTestBase\AbpTestBase.csproj" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
</ItemGroup>
</Project>

61
framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUowTestModule.cs

@ -0,0 +1,61 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.AspNetCore.Mvc;
using Volo.Abp.AspNetCore.TestBase;
using Volo.Abp.Autofac;
using Volo.Abp.Data;
using Volo.Abp.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore.Sqlite;
using Volo.Abp.Modularity;
namespace Volo.Abp.AspNetCore.Uow;
[DependsOn(
typeof(AbpAspNetCoreTestBaseModule),
typeof(AbpAspNetCoreMvcModule),
typeof(AbpEntityFrameworkCoreSqliteModule),
typeof(AbpAutofacModule)
)]
public class AbpAspNetCoreUowTestModule : AbpModule
{
private readonly AbpUnitTestSqliteDatabase _database = new AbpUnitTestSqliteDatabase();
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddAbpDbContext<UowVisibilityTestDbContext>(options =>
{
options.AddDefaultRepositories(includeAllEntities: true);
});
Configure<AbpDbConnectionOptions>(options =>
{
options.ConnectionStrings.Default = _database.ConnectionString;
});
Configure<AbpDbContextOptions>(options =>
{
options.Configure(dbContext => dbContext.UseSqlite().AddAbpDbContextOptionsExtension());
});
_database.CreateTables(new UowVisibilityTestDbContext(
new DbContextOptionsBuilder<UowVisibilityTestDbContext>()
.UseSqlite(_database.ConnectionString)
.AddAbpDbContextOptionsExtension()
.Options));
}
public override void OnApplicationInitialization(ApplicationInitializationContext context)
{
var app = context.GetApplicationBuilder();
app.UseRouting();
app.UseUnitOfWork();
app.UseConfiguredEndpoints();
}
public override void OnApplicationShutdown(ApplicationShutdownContext context)
{
_database.Dispose();
}
}

15
framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/Program.cs

@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Hosting;
using Volo.Abp.AspNetCore.TestBase;
using Volo.Abp.AspNetCore.Uow;
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
EnvironmentName = Environments.Staging
});
await builder.RunAbpModuleAsync<AbpAspNetCoreUowTestModule>();
public partial class Program
{
}

120
framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UnitOfWorkMiddleware_Relational_Tests.cs

@ -0,0 +1,120 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Shouldly;
using Volo.Abp.AspNetCore.TestBase;
using Xunit;
namespace Volo.Abp.AspNetCore.Uow;
public class UnitOfWorkMiddleware_Relational_Tests : AbpWebApplicationFactoryIntegratedTest<Program>
{
private void EnableCompleteOnResponseStarting()
{
ServiceProvider.GetRequiredService<IOptions<AbpAspNetCoreUnitOfWorkOptions>>()
.Value.CompleteUnitOfWorkOnResponseStarting = true;
}
private async Task<int> CountAsync(string name)
{
var response = await Client.GetAsync("/api/uow-visibility/count?name=" + name);
response.EnsureSuccessStatusCode();
return int.Parse(await response.Content.ReadAsStringAsync());
}
[Fact]
public async Task Row_Written_During_Request_Is_Visible_From_An_Independent_Connection_On_Response_Start()
{
EnableCompleteOnResponseStarting();
var response = await Client.GetAsync("/api/uow-visibility/insert-then-read");
response.EnsureSuccessStatusCode();
(await response.Content.ReadAsStringAsync()).ShouldBe("inserted:visible");
}
[Fact]
public async Task Row_Written_During_Request_Is_Still_Committed_When_The_Feature_Is_Disabled()
{
var name = Guid.NewGuid().ToString("N");
var insert = await Client.GetAsync("/api/uow-visibility/insert?name=" + name);
insert.EnsureSuccessStatusCode();
(await CountAsync(name)).ShouldBe(1);
}
[Fact]
public async Task Exception_Before_Response_Rolls_Back_The_Written_Row()
{
EnableCompleteOnResponseStarting();
var name = Guid.NewGuid().ToString("N");
var insert = await Client.GetAsync("/api/uow-visibility/insert-then-throw?name=" + name);
insert.IsSuccessStatusCode.ShouldBeFalse();
(await CountAsync(name)).ShouldBe(0);
}
[Fact]
public async Task Committed_Row_Survives_An_Exception_Raised_After_The_Response_Started()
{
EnableCompleteOnResponseStarting();
var name = Guid.NewGuid().ToString("N");
await Should.ThrowAsync<HttpRequestException>(async () =>
{
var response = await Client.GetAsync("/api/uow-visibility/insert-flush-then-throw?name=" + name);
await response.Content.ReadAsStringAsync();
});
(await CountAsync(name)).ShouldBe(1);
}
[Fact]
public async Task Committed_Row_Survives_A_Completed_Handler_Failing_On_Response_Start()
{
EnableCompleteOnResponseStarting();
var name = Guid.NewGuid().ToString("N");
// The handler's error must surface as-is (not masked by a second "already requested" completion);
// the row is committed regardless, since the handler runs after commit.
Exception surfaced = null;
try
{
var response = await Client.GetAsync("/api/uow-visibility/insert-flush-throwing-completed-handler?name=" + name);
await response.Content.ReadAsStringAsync();
}
catch (Exception ex)
{
surfaced = ex;
}
surfaced.ShouldNotBeNull();
surfaced.ToString().ShouldContain("boom in a completed handler");
surfaced.ToString().ShouldNotContain("already");
(await CountAsync(name)).ShouldBe(1);
}
[Fact]
public async Task A_Failing_Commit_On_Response_Start_Does_Not_Persist_Data()
{
EnableCompleteOnResponseStarting();
var name = Guid.NewGuid().ToString("N");
Exception surfaced = null;
try
{
var response = await Client.GetAsync("/api/uow-visibility/insert-then-fail-commit?name=" + name);
await response.Content.ReadAsStringAsync();
}
catch (Exception ex)
{
surfaced = ex;
}
// The commit fails on response start, so the request must surface an error and persist nothing.
surfaced.ShouldNotBeNull();
(await CountAsync(name)).ShouldBe(0);
}
}

107
framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UowVisibilityController.cs

@ -0,0 +1,107 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp;
using Volo.Abp.AspNetCore.Mvc;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Uow;
namespace Volo.Abp.AspNetCore.Uow;
[Route("api/uow-visibility")]
public class UowVisibilityController : AbpController
{
private readonly IRepository<UowVisibilityTestEntity, Guid> _repository;
public UowVisibilityController(IRepository<UowVisibilityTestEntity, Guid> repository)
{
_repository = repository;
}
[HttpGet]
[Route("insert-then-read")]
[UnitOfWork(isTransactional: true)]
public async Task InsertThenRead()
{
var name = Guid.NewGuid().ToString("N");
await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), name));
await Response.WriteAsync("inserted");
await Response.Body.FlushAsync();
int count;
using (var uow = UnitOfWorkManager.Begin(requiresNew: true, isTransactional: false))
{
count = await _repository.CountAsync(x => x.Name == name);
await uow.CompleteAsync();
}
await Response.WriteAsync(count == 1 ? ":visible" : ":not-visible");
}
[HttpGet]
[Route("insert")]
[UnitOfWork(isTransactional: true)]
public async Task Insert(string name)
{
await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), name));
await Response.WriteAsync("inserted");
}
[HttpGet]
[Route("count")]
public async Task Count(string name)
{
var count = await _repository.CountAsync(x => x.Name == name);
await Response.WriteAsync(count.ToString());
}
// Insert (autoSave sends the INSERT to the transaction) then throw before the response: must roll back.
[HttpGet]
[Route("insert-then-throw")]
[UnitOfWork(isTransactional: true)]
public async Task InsertThenThrow(string name)
{
await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), name), autoSave: true);
throw new AbpException("boom before the response started");
}
// Insert, flush the response (committed here when enabled), then throw: the committed row survives.
[HttpGet]
[Route("insert-flush-then-throw")]
[UnitOfWork(isTransactional: true)]
public async Task InsertFlushThenThrow(string name)
{
await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), name));
await Response.WriteAsync("inserted");
await Response.Body.FlushAsync();
throw new AbpException("boom after the response started");
}
// Insert, register a completed handler that throws (runs after commit), then flush.
[HttpGet]
[Route("insert-flush-throwing-completed-handler")]
[UnitOfWork(isTransactional: true)]
public async Task InsertFlushWithThrowingCompletedHandler(string name)
{
await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), name));
CurrentUnitOfWork.OnCompleted(() => throw new AbpException("boom in a completed handler"));
await Response.WriteAsync("inserted");
await Response.Body.FlushAsync();
}
// Insert a valid row plus an invalid one (Name is required): the commit at response start fails.
[HttpGet]
[Route("insert-then-fail-commit")]
[UnitOfWork(isTransactional: true)]
public async Task InsertThenFailCommit(string name)
{
await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), name));
await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), null));
await Response.WriteAsync("inserted");
await Response.Body.FlushAsync();
}
}

46
framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UowVisibilityTestEntity.cs

@ -0,0 +1,46 @@
using System;
using Microsoft.EntityFrameworkCore;
using Volo.Abp.Data;
using Volo.Abp.Domain.Entities;
using Volo.Abp.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore.Modeling;
namespace Volo.Abp.AspNetCore.Uow;
public class UowVisibilityTestEntity : AggregateRoot<Guid>
{
public string Name { get; set; }
protected UowVisibilityTestEntity()
{
}
public UowVisibilityTestEntity(Guid id, string name)
: base(id)
{
Name = name;
}
}
[ConnectionStringName("Default")]
public class UowVisibilityTestDbContext : AbpDbContext<UowVisibilityTestDbContext>
{
public DbSet<UowVisibilityTestEntity> UowVisibilityTestEntities { get; set; }
public UowVisibilityTestDbContext(DbContextOptions<UowVisibilityTestDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<UowVisibilityTestEntity>(b =>
{
b.ToTable("UowVisibilityTestEntities");
b.ConfigureByConvention();
b.Property(x => x.Name).IsRequired();
});
}
}

40
modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/AbpOpenIddictAspNetCoreModule.cs

@ -1,10 +1,14 @@
using Microsoft.AspNetCore.Identity;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using Volo.Abp.AspNetCore.MultiTenancy;
using Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared;
using Volo.Abp.AspNetCore.Uow;
using Volo.Abp.Modularity;
using Volo.Abp.OpenIddict.Globalization;
using Volo.Abp.OpenIddict.Scopes;
@ -45,6 +49,40 @@ public class AbpOpenIddictAspNetCoreModule : AbpModule
{
options.RemoveClientIdClaim();
});
// Commit tokens/authorizations/sessions written during sign-in before the response is flushed.
// Derived from the configured OpenIddict server endpoint paths (including the device endpoint).
context.Services.AddOptions<AbpAspNetCoreUnitOfWorkOptions>()
.Configure<IOptions<OpenIddictServerOptions>>((uowOptions, serverOptions) =>
{
foreach (var path in GetServerEndpointPaths(serverOptions.Value))
{
if (!uowOptions.CompleteUnitOfWorkOnResponseStartingUrls.Contains(path))
{
uowOptions.CompleteUnitOfWorkOnResponseStartingUrls.Add(path);
}
}
});
}
private static IEnumerable<string> GetServerEndpointPaths(OpenIddictServerOptions serverOptions)
{
var endpoints = serverOptions.TokenEndpointUris
.Concat(serverOptions.AuthorizationEndpointUris)
.Concat(serverOptions.DeviceAuthorizationEndpointUris)
.Concat(serverOptions.PushedAuthorizationEndpointUris)
.Concat(serverOptions.EndSessionEndpointUris)
.Concat(serverOptions.RevocationEndpointUris)
.Concat(serverOptions.EndUserVerificationEndpointUris);
foreach (var uri in endpoints)
{
var path = uri.IsAbsoluteUri ? uri.AbsolutePath : uri.OriginalString;
if (!string.IsNullOrWhiteSpace(path))
{
yield return "/" + path.TrimStart('/');
}
}
}
private void AddOpenIddictServer(IServiceCollection services)

6
modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo.Abp.OpenIddict.AspNetCore.Tests.csproj

@ -5,6 +5,8 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace />
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<PreserveCompilationReferences>true</PreserveCompilationReferences>
</PropertyGroup>
<ItemGroup>
@ -16,6 +18,10 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Volo.Abp.OpenIddict.AspNetCore\Volo.Abp.OpenIddict.AspNetCore.csproj" />
<ProjectReference Include="..\..\src\Volo.Abp.OpenIddict.EntityFrameworkCore\Volo.Abp.OpenIddict.EntityFrameworkCore.csproj" />
<ProjectReference Include="..\..\..\..\framework\src\Volo.Abp.EntityFrameworkCore.Sqlite\Volo.Abp.EntityFrameworkCore.Sqlite.csproj" />
<ProjectReference Include="..\..\..\..\framework\src\Volo.Abp.AspNetCore.TestBase\Volo.Abp.AspNetCore.TestBase.csproj" />
<ProjectReference Include="..\..\..\..\framework\src\Volo.Abp.Autofac\Volo.Abp.Autofac.csproj" />
</ItemGroup>
</Project>

58
modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenEndpoint_Integration_Tests.cs

@ -0,0 +1,58 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Shouldly;
using Volo.Abp.AspNetCore.TestBase;
using Volo.Abp.AspNetCore.Uow;
using Xunit;
namespace Volo.Abp.OpenIddict.Integration;
// A real "/connect/token" (client_credentials) request through the OpenIddict server. A probe registered
// outside UseUnitOfWork reads the token count from an independent connection at response start.
public class OpenIddictTokenEndpoint_Integration_Tests : AbpWebApplicationFactoryIntegratedTest<Program>
{
private AbpAspNetCoreUnitOfWorkOptions Options =>
ServiceProvider.GetRequiredService<IOptions<AbpAspNetCoreUnitOfWorkOptions>>().Value;
private long? TokenCountAtResponseStart =>
ServiceProvider.GetRequiredService<TokenVisibilityRecorder>().TokenCountAtResponseStart;
private Task<HttpResponseMessage> RequestTokenAsync()
{
return Client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = "test-client",
["client_secret"] = "test-secret"
}));
}
[Fact]
public async Task Token_Row_Is_Committed_Before_The_Connect_Token_Response_Is_Sent()
{
// The OpenIddict module opts "/connect" in by default.
var response = await RequestTokenAsync();
response.StatusCode.ShouldBe(HttpStatusCode.OK);
(await response.Content.ReadAsStringAsync()).ShouldContain("access_token");
TokenCountAtResponseStart.ShouldBe(1);
}
[Fact]
public async Task Without_The_Opt_In_The_Token_Is_Not_Committed_When_The_Response_Starts()
{
// Negative control: without the opt-in the token is committed only at the end of the pipeline,
// so the probe reads 0. This proves the positive case genuinely observes response-start timing.
Options.CompleteUnitOfWorkOnResponseStartingUrls.Clear();
Options.CompleteUnitOfWorkOnResponseStarting = false;
var response = await RequestTokenAsync();
response.StatusCode.ShouldBe(HttpStatusCode.OK);
TokenCountAtResponseStart.ShouldBe(0);
}
}

150
modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenIntegrationTestModule.cs

@ -0,0 +1,150 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using Volo.Abp.AspNetCore.TestBase;
using Volo.Abp.AspNetCore.Uow;
using Volo.Abp.Data;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore.Sqlite;
using Volo.Abp.Modularity;
using Volo.Abp.OpenIddict.Applications;
using Volo.Abp.OpenIddict.EntityFrameworkCore;
using Volo.Abp.OpenIddict.Tokens;
using Volo.Abp.Uow;
using Volo.Abp.Autofac;
namespace Volo.Abp.OpenIddict.Integration;
public class TokenVisibilityRecorder
{
public long? TokenCountAtResponseStart { get; set; }
}
[DependsOn(
typeof(AbpAspNetCoreTestBaseModule),
typeof(AbpOpenIddictAspNetCoreModule),
typeof(AbpOpenIddictEntityFrameworkCoreModule),
typeof(AbpEntityFrameworkCoreSqliteModule),
typeof(AbpAutofacModule)
)]
public class OpenIddictTokenIntegrationTestModule : AbpModule
{
// File-based SQLite (not shared-cache in-memory) so an independent connection can read committed
// state while another holds an open write transaction, without the shared-cache single-writer deadlock.
private readonly string _databasePath = Path.Combine(Path.GetTempPath(), $"abp-oidc-uow-{Guid.NewGuid():N}.db");
private string ConnectionString => $"Data Source={_databasePath};Pooling=False";
public override void PreConfigureServices(ServiceConfigurationContext context)
{
PreConfigure<AbpOpenIddictAspNetCoreOptions>(options =>
{
options.AddDevelopmentEncryptionAndSigningCertificate = false;
});
PreConfigure<OpenIddictServerBuilder>(builder =>
{
builder.AddEphemeralEncryptionKey();
builder.AddEphemeralSigningKey();
builder.UseAspNetCore().DisableTransportSecurityRequirement();
});
}
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddSingleton<TokenVisibilityRecorder>();
// The OpenIddict controllers (including the token endpoint) live in a referenced assembly.
context.Services.GetSingletonInstance<ApplicationPartManager>()
.ApplicationParts.AddIfNotContains(typeof(AbpOpenIddictAspNetCoreModule).Assembly);
using (var dbContext = new OpenIddictDbContext(
new DbContextOptionsBuilder<OpenIddictDbContext>().UseSqlite(ConnectionString).Options))
{
dbContext.Database.EnsureCreated();
}
Configure<AbpDbConnectionOptions>(options =>
{
options.ConnectionStrings.Default = ConnectionString;
});
Configure<AbpDbContextOptions>(options =>
{
options.Configure(c => c.UseSqlite());
});
}
public override void OnApplicationInitialization(ApplicationInitializationContext context)
{
SeedClientAsync(context.ServiceProvider).GetAwaiter().GetResult();
var app = context.GetApplicationBuilder();
app.UseRouting();
// Registered before UseUnitOfWork so its OnStarting runs after the unit of work commits
// (callbacks run in reverse order): reads the token count from an independent connection.
app.Use(async (ctx, next) =>
{
if (ctx.Request.Path.StartsWithSegments("/connect/token"))
{
ctx.Response.OnStarting(async () =>
{
var recorder = ctx.RequestServices.GetRequiredService<TokenVisibilityRecorder>();
var uowManager = ctx.RequestServices.GetRequiredService<IUnitOfWorkManager>();
using var uow = uowManager.Begin(requiresNew: true, isTransactional: false);
var repository = ctx.RequestServices.GetRequiredService<IRepository<OpenIddictToken, Guid>>();
recorder.TokenCountAtResponseStart = await repository.GetCountAsync();
await uow.CompleteAsync();
});
}
await next();
});
app.UseAuthentication();
app.UseUnitOfWork();
app.UseAuthorization();
app.UseConfiguredEndpoints();
}
public override void OnApplicationShutdown(ApplicationShutdownContext context)
{
if (File.Exists(_databasePath))
{
File.Delete(_databasePath);
}
}
private static async Task SeedClientAsync(IServiceProvider serviceProvider)
{
using var scope = serviceProvider.CreateScope();
var uowManager = scope.ServiceProvider.GetRequiredService<IUnitOfWorkManager>();
using var uow = uowManager.Begin();
var applicationManager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
if (await applicationManager.FindByClientIdAsync("test-client") == null)
{
await applicationManager.CreateAsync(new AbpApplicationDescriptor
{
ClientId = "test-client",
ClientSecret = "test-secret",
DisplayName = "Test Client",
ClientType = OpenIddictConstants.ClientTypes.Confidential,
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.ClientCredentials
}
});
}
await uow.CompleteAsync();
}
}

15
modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/Program.cs

@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Hosting;
using Volo.Abp.AspNetCore.TestBase;
using Volo.Abp.OpenIddict.Integration;
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
EnvironmentName = Environments.Staging
});
await builder.RunAbpModuleAsync<OpenIddictTokenIntegrationTestModule>();
public partial class Program
{
}
Loading…
Cancel
Save