diff --git a/framework/Volo.Abp.slnx b/framework/Volo.Abp.slnx index 5f4adbd622..6b9ad8af61 100644 --- a/framework/Volo.Abp.slnx +++ b/framework/Volo.Abp.slnx @@ -194,6 +194,7 @@ + diff --git a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUnitOfWorkOptions.cs b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUnitOfWorkOptions.cs index 3949bb6639..a38d09f1fb 100644 --- a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUnitOfWorkOptions.cs +++ b/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. /// public List IgnoredUrls { get; } = new List(); + + /// + /// Completes the request unit of work just before the response starts (on + /// HttpResponse.OnStarting) 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 . + /// + /// 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, and an active child + /// unit of work scope (begun without requiresNew), are left to their owners and the request unit of + /// work then completes at the end of the pipeline as usual. + /// + /// + public bool CompleteUnitOfWorkOnResponseStarting { get; set; } = false; + + /// + /// Request path prefixes that opt-in to even when + /// it is globally disabled. A request whose path starts with one of these values (for example + /// "/connect") is included, matched like . Use + /// to enable it for every request handled by the middleware. + /// + public List CompleteUnitOfWorkOnResponseStartingUrls { get; } = new List(); } diff --git a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpUnitOfWorkMiddleware.cs b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpUnitOfWorkMiddleware.cs index 74833bf748..64c507d260 100644 --- a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpUnitOfWorkMiddleware.cs +++ b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpUnitOfWorkMiddleware.cs @@ -37,8 +37,36 @@ public class AbpUnitOfWorkMiddleware : AbpMiddlewareBase, ITransientDependency using (var uow = _unitOfWorkManager.Reserve(UnitOfWork.UnitOfWorkReservationName)) { + var completionStarted = false; + + if (!context.Response.HasStarted && ShouldCompleteOnResponseStarting(context)) + { + context.Response.OnStarting(async () => + { + // Skip if the completion has already been started at the end of the pipeline; + // the response is then being started from inside that completion (e.g. by an + // event handler writing to the response), so completing again would fail. + // A nested (requiresNew) unit of work that is current and an active child + // unit of work scope are left to their owners; the request unit of work then + // completes at the end of the pipeline as usual. + if (!completionStarted && + _unitOfWorkManager.Current == uow && + !uow.HasActiveChildUnitOfWorks()) + { + // Set before completing so a post-commit failure isn't masked by the completion below. + completionStarted = true; + await uow.CompleteAsync(_cancellationTokenProvider.Token); + } + }); + } + await next(context); - await uow.CompleteAsync(_cancellationTokenProvider.Token); + + if (!completionStarted) + { + completionStarted = true; + await uow.CompleteAsync(_cancellationTokenProvider.Token); + } } } @@ -48,6 +76,13 @@ public class AbpUnitOfWorkMiddleware : AbpMiddlewareBase, ITransientDependency _options.IgnoredUrls.Any(x => context.Request.Path.Value.StartsWith(x, StringComparison.OrdinalIgnoreCase)); } + private bool ShouldCompleteOnResponseStarting(HttpContext context) + { + return _options.CompleteUnitOfWorkOnResponseStarting || + (context.Request.Path.Value != null && + _options.CompleteUnitOfWorkOnResponseStartingUrls.Any(x => context.Request.Path.Value.StartsWith(x, StringComparison.OrdinalIgnoreCase))); + } + protected async override Task ShouldSkipAsync(HttpContext context, RequestDelegate next) { // Blazor components will render concurrently, so we need to skip the middleware for them. diff --git a/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/ChildUnitOfWork.cs b/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/ChildUnitOfWork.cs index 5f400abcfd..14625aaf4a 100644 --- a/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/ChildUnitOfWork.cs +++ b/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/ChildUnitOfWork.cs @@ -30,12 +30,14 @@ internal class ChildUnitOfWork : IUnitOfWork public Dictionary Items => _parent.Items; private readonly IUnitOfWork _parent; + private bool _isDisposed; public ChildUnitOfWork([NotNull] IUnitOfWork parent) { Check.NotNull(parent, nameof(parent)); _parent = parent; + _parent.IncrementActiveChildUnitOfWorkCount(); _parent.Failed += (sender, args) => { Failed.InvokeSafely(sender!, args); }; _parent.Disposed += (sender, args) => { Disposed.InvokeSafely(sender!, args); }; @@ -122,7 +124,13 @@ internal class ChildUnitOfWork : IUnitOfWork public void Dispose() { + if (_isDisposed) + { + return; + } + _isDisposed = true; + _parent.DecrementActiveChildUnitOfWorkCount(); } public override string ToString() diff --git a/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/UnitOfWorkExtensions.cs b/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/UnitOfWorkExtensions.cs index a732e5ad21..a037023472 100644 --- a/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/UnitOfWorkExtensions.cs +++ b/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/UnitOfWorkExtensions.cs @@ -7,6 +7,8 @@ namespace Volo.Abp.Uow; public static class UnitOfWorkExtensions { + private const string ActiveChildUnitOfWorkCountItemKey = "_AbpActiveChildUnitOfWorkCount"; + public static bool IsReservedFor([NotNull] this IUnitOfWork unitOfWork, string reservationName) { Check.NotNull(unitOfWork, nameof(unitOfWork)); @@ -14,6 +16,31 @@ public static class UnitOfWorkExtensions return unitOfWork.IsReserved && unitOfWork.ReservationName == reservationName; } + /// + /// Checks if there is an active (not yet disposed) child unit of work scope over the given + /// unit of work, i.e. a scope created by without + /// requiresNew while this unit of work was current. Such a scope shares this unit of work, + /// so it should not be completed while the scope is still active. + /// + public static bool HasActiveChildUnitOfWorks([NotNull] this IUnitOfWork unitOfWork) + { + Check.NotNull(unitOfWork, nameof(unitOfWork)); + + return unitOfWork.Items.GetOrDefault(ActiveChildUnitOfWorkCountItemKey) is int count && count > 0; + } + + internal static void IncrementActiveChildUnitOfWorkCount(this IUnitOfWork unitOfWork) + { + var count = unitOfWork.Items.GetOrDefault(ActiveChildUnitOfWorkCountItemKey) as int? ?? 0; + unitOfWork.Items[ActiveChildUnitOfWorkCountItemKey] = count + 1; + } + + internal static void DecrementActiveChildUnitOfWorkCount(this IUnitOfWork unitOfWork) + { + var count = unitOfWork.Items.GetOrDefault(ActiveChildUnitOfWorkCountItemKey) as int? ?? 0; + unitOfWork.Items[ActiveChildUnitOfWorkCountItemKey] = Math.Max(0, count - 1); + } + public static void AddItem([NotNull] this IUnitOfWork unitOfWork, string key, TValue value) where TValue : class { diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/ResponseWritingTestEvent.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/ResponseWritingTestEvent.cs new file mode 100644 index 0000000000..989ec5b294 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/ResponseWritingTestEvent.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Volo.Abp.DependencyInjection; +using Volo.Abp.EventBus; + +namespace Volo.Abp.AspNetCore.Mvc.Uow; + +public class ResponseWritingTestEvent +{ +} + +/// +/// Writes to the HTTP response from a local event handler. When the event is published inside the +/// request unit of work, this runs during the unit of work completion at the end of the pipeline +/// and starts the response from inside that completion. +/// +public class ResponseWritingTestEventHandler : ILocalEventHandler, ITransientDependency +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public ResponseWritingTestEventHandler(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public async Task HandleEventAsync(ResponseWritingTestEvent eventData) + { + var response = _httpContextAccessor.HttpContext?.Response; + if (response != null) + { + await response.WriteAsync("event-written"); + } + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/TestUnitOfWorkConfig.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/TestUnitOfWorkConfig.cs index 8f40f4b8e0..9dd87b56b1 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/TestUnitOfWorkConfig.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/TestUnitOfWorkConfig.cs @@ -7,4 +7,6 @@ public class TestUnitOfWorkConfig : ISingletonDependency public const string ExceptionOnCompleteMessage = "TestUnitOfWork configured for exception"; public bool ThrowExceptionOnComplete { get; set; } + + public bool? UowCompletedAfterResponseFlush { get; set; } } diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs index 05e5d7b524..50d058903e 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs @@ -1,12 +1,19 @@ -using System.Net.Http; +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>().Value; + [Fact] public async Task Get_Actions_Should_Not_Be_Transactional() { @@ -27,4 +34,114 @@ public class UnitOfWorkMiddleware_Tests : AspNetCoreMvcTestBase var result = await Client.SendAsync(requestMessage); result.IsSuccessStatusCode.ShouldBeTrue(); } + + [Fact] + 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 Ambient_Uow_Is_Not_Completed_On_Response_Start_By_Default() + { + 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(async () => + { + var response = await Client.GetAsync("/api/unitofwork-test/CommitThenThrowAfterResponseFlush"); + await response.Content.ReadAsStringAsync(); + }); + + ServiceProvider.GetRequiredService() + .UowCompletedAfterResponseFlush.ShouldBe(true); + } + + [Fact] + public async Task Repository_Access_After_Response_Flush_Runs_Outside_The_Request_Uow() + { + Options.CompleteUnitOfWorkOnResponseStarting = true; + + var body = await GetResponseAsStringAsync("/api/unitofwork-test/ReadRepositoryAfterResponseFlush"); + body.ShouldBe("before=ok(1);after=ok(1,ambient=null)"); + } + + [Fact] + public async Task Raw_Database_Provider_After_Response_Flush_Throws() + { + Options.CompleteUnitOfWorkOnResponseStarting = true; + + var body = await GetResponseAsStringAsync("/api/unitofwork-test/RawDatabaseProviderAfterResponseFlush"); + body.ShouldBe("first:threw-AbpException"); + } + + [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: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 Response_Flush_Inside_A_Child_Uow_Scope_Should_Not_Complete_The_Request_Uow() + { + Options.CompleteUnitOfWorkOnResponseStarting = true; + + // A child scope (Begin without requiresNew) shares the request unit of work, so completing + // it on response start would commit under the still-active scope; it is left to the end of + // the pipeline instead, like a nested (requiresNew) unit of work. + var body = await GetResponseAsStringAsync("/api/unitofwork-test/ChildUowDuringResponseFlush"); + body.ShouldBe("first:request-not-completed"); + } + + [Fact] + public async Task An_Event_Handler_Starting_The_Response_During_The_End_Of_Pipeline_Completion_Should_Not_Fail() + { + Options.CompleteUnitOfWorkOnResponseStarting = true; + + // The response does not start during the pipeline here, so the middleware completes the + // unit of work at its end; the event handler then starts the response from inside that + // completion. The OnStarting callback must not attempt a second completion (which would + // throw "Completion has already been requested for this unit of work"). + var body = await GetResponseAsStringAsync("/api/unitofwork-test/PublishEventThatWritesResponseOnCompletion"); + body.ShouldBe("event-written"); + } } diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs index ebf2c12a6a..77cf3f07b4 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs @@ -1,5 +1,14 @@ -using Microsoft.AspNetCore.Mvc; +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Shouldly; +using Volo.Abp; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.EventBus.Local; +using Volo.Abp.MemoryDb; +using Volo.Abp.TestApp.MemoryDb; +using Volo.Abp.TestApp.Domain; using Volo.Abp.Uow; namespace Volo.Abp.AspNetCore.Mvc.Uow; @@ -64,4 +73,149 @@ public class UnitOfWorkTestController : AbpController _testUnitOfWorkConfig.ThrowExceptionOnComplete = true; } + + [HttpGet] + [Route("CommitBeforeResponseFlush")] + public async Task CommitBeforeResponseFlush() + { + var uow = CurrentUnitOfWork; + uow.ShouldNotBeNull(); + + // Start the response from inside the pipeline, before the middleware would commit. + await Response.WriteAsync("first"); + await Response.Body.FlushAsync(); + + await Response.WriteAsync(uow.IsCompleted ? ":completed" : ":not-completed"); + } + + [HttpGet] + [Route("CommitThenThrowAfterResponseFlush")] + public async Task CommitThenThrowAfterResponseFlush() + { + var uow = CurrentUnitOfWork; + + await Response.WriteAsync("first"); + await Response.Body.FlushAsync(); + + _testUnitOfWorkConfig.UowCompletedAfterResponseFlush = uow.IsCompleted; + + throw new UserFriendlyException("boom after the response was already flushed"); + } + + [HttpGet] + [Route("ReadRepositoryAfterResponseFlush")] + public async Task ReadRepositoryAfterResponseFlush() + { + var repository = LazyServiceProvider.LazyGetRequiredService>(); + + var before = (await repository.GetListAsync()).Count; + await Response.WriteAsync($"before=ok({before})"); + await Response.Body.FlushAsync(); + + string after; + try + { + var count = (await repository.GetListAsync()).Count; + after = $";after=ok({count},ambient={(UnitOfWorkManager.Current == null ? "null" : "present")})"; + } + catch (Exception ex) + { + after = $";after=threw:{ex.GetType().Name}"; + } + + await Response.WriteAsync(after); + } + + [HttpGet] + [Route("RawDatabaseProviderAfterResponseFlush")] + public async Task RawDatabaseProviderAfterResponseFlush() + { + var databaseProvider = LazyServiceProvider + .LazyGetRequiredService>(); + + await Response.WriteAsync("first"); + await Response.Body.FlushAsync(); + + string outcome; + try + { + await databaseProvider.GetDatabaseAsync(); + outcome = ":ok"; + } + catch (AbpException) + { + // Raw provider access has no ambient uow once the response started, so it throws. + outcome = ":threw-AbpException"; + } + + await Response.WriteAsync(outcome); + } + + [HttpGet] + [Route("NestedUowDuringResponseFlush")] + public async Task NestedUowDuringResponseFlush() + { + using (var nested = UnitOfWorkManager.Begin(requiresNew: true, isTransactional: false)) + { + 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 + { + await nested.CompleteAsync(); + outcome = ":nested-completed-by-owner"; + } + catch (AbpException) + { + outcome = ":nested-already-completed"; + } + + 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(); + } + + [HttpGet] + [Route("ChildUowDuringResponseFlush")] + public async Task ChildUowDuringResponseFlush() + { + var requestUow = CurrentUnitOfWork!; + + using (UnitOfWorkManager.Begin()) + { + await Response.WriteAsync("first"); + await Response.Body.FlushAsync(); + + // The request unit of work must not be completed on response start while a child + // unit of work scope (begun without requiresNew) is still active over it. + await Response.WriteAsync(requestUow.IsCompleted ? ":request-completed" : ":request-not-completed"); + } + } + + [HttpGet] + [Route("PublishEventThatWritesResponseOnCompletion")] + public async Task PublishEventThatWritesResponseOnCompletion() + { + // Published inside the request unit of work, so the handler runs while the middleware is + // completing it at the end of the pipeline. The handler writes to the response, which starts + // it mid-completion; the middleware's OnStarting callback must not try to complete again. + await LazyServiceProvider.LazyGetRequiredService() + .PublishAsync(new ResponseWritingTestEvent()); + + // Ok() sets 200 without writing the body, so the response does not start inside the pipeline. + return Ok(); + } } diff --git a/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo.Abp.AspNetCore.Uow.Tests.csproj b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo.Abp.AspNetCore.Uow.Tests.csproj new file mode 100644 index 0000000000..08b17d2002 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo.Abp.AspNetCore.Uow.Tests.csproj @@ -0,0 +1,26 @@ + + + + + + net10.0 + Volo.Abp.AspNetCore.Uow.Tests + Volo.Abp.AspNetCore.Uow.Tests + true + false + false + false + true + + + + + + + + + + + + + diff --git a/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUowTestModule.cs b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUowTestModule.cs new file mode 100644 index 0000000000..df2256258f --- /dev/null +++ b/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(options => + { + options.AddDefaultRepositories(includeAllEntities: true); + }); + + Configure(options => + { + options.ConnectionStrings.Default = _database.ConnectionString; + }); + + Configure(options => + { + options.Configure(dbContext => dbContext.UseSqlite().AddAbpDbContextOptionsExtension()); + }); + + _database.CreateTables(new UowVisibilityTestDbContext( + new DbContextOptionsBuilder() + .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(); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/Program.cs b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/Program.cs new file mode 100644 index 0000000000..98ab220169 --- /dev/null +++ b/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(); + +public partial class Program +{ +} diff --git a/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UnitOfWorkMiddleware_Relational_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UnitOfWorkMiddleware_Relational_Tests.cs new file mode 100644 index 0000000000..2d20c60005 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UnitOfWorkMiddleware_Relational_Tests.cs @@ -0,0 +1,145 @@ +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 +{ + private void EnableCompleteOnResponseStarting() + { + ServiceProvider.GetRequiredService>() + .Value.CompleteUnitOfWorkOnResponseStarting = true; + } + + private async Task 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(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); + } + + [Fact] + public async Task Result_Serialization_Failure_Rolls_Back_And_Does_Not_Commit_On_The_Error_Response() + { + EnableCompleteOnResponseStarting(); + var name = Guid.NewGuid().ToString("N"); + + HttpResponseMessage response = null; + Exception surfaced = null; + try + { + response = await Client.GetAsync("/api/uow-visibility/insert-then-throw-in-serialization?name=" + name); + await response.Content.ReadAsStringAsync(); + } + catch (Exception ex) + { + surfaced = ex; + } + + // The action ran and saved the row, then serializing the result failed. The request must therefore + // fail with a server error (not a 404 or a success), and the error response, written by the upstream + // exception middleware after the request unit of work is disposed, must not commit the failed request. + (surfaced != null || (response != null && (int)response.StatusCode >= 500)).ShouldBeTrue(); + (await CountAsync(name)).ShouldBe(0); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UowVisibilityController.cs b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UowVisibilityController.cs new file mode 100644 index 0000000000..ad0af449a2 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UowVisibilityController.cs @@ -0,0 +1,124 @@ +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 _repository; + + public UowVisibilityController(IRepository 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(); + } + + // The action succeeds (so the action filter saves changes), then serializing the object result throws + // before the response starts. The upstream exception middleware writes the error response after the + // request unit of work is disposed, so response-start completion must not commit the failed request. + [HttpGet] + [Route("insert-then-throw-in-serialization")] + [UnitOfWork(isTransactional: true)] + public async Task InsertThenThrowInSerialization(string name) + { + await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), name)); + return Ok(new ThrowingOnSerializeDto()); + } + + public class ThrowingOnSerializeDto + { + public string Value => throw new AbpException("boom while serializing the object result"); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UowVisibilityTestEntity.cs b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UowVisibilityTestEntity.cs new file mode 100644 index 0000000000..f3595ddd33 --- /dev/null +++ b/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 +{ + public string Name { get; set; } + + protected UowVisibilityTestEntity() + { + } + + public UowVisibilityTestEntity(Guid id, string name) + : base(id) + { + Name = name; + } +} + +[ConnectionStringName("Default")] +public class UowVisibilityTestDbContext : AbpDbContext +{ + public DbSet UowVisibilityTestEntities { get; set; } + + public UowVisibilityTestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(b => + { + b.ToTable("UowVisibilityTestEntities"); + b.ConfigureByConvention(); + b.Property(x => x.Name).IsRequired(); + }); + } +} diff --git a/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/AbpOpenIddictAspNetCoreModule.cs b/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/AbpOpenIddictAspNetCoreModule.cs index 26729669e7..7e025a80b8 100644 --- a/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/AbpOpenIddictAspNetCoreModule.cs +++ b/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,37 @@ public class AbpOpenIddictAspNetCoreModule : AbpModule { options.RemoveClientIdClaim(); }); + + // Complete data written while processing OpenIddict requests before the response starts. + // Derived from the configured server endpoint paths so remapped endpoints are covered too. + context.Services.AddOptions() + .Configure>((uowOptions, serverOptions) => + { + foreach (var path in GetServerEndpointPaths(serverOptions.Value)) + { + uowOptions.CompleteUnitOfWorkOnResponseStartingUrls.AddIfNotContains(path); + } + }); + } + + private static IEnumerable 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.TrimStart('/'); + if (path.Length > 1) + { + yield return path; + } + } } private void AddOpenIddictServer(IServiceCollection services) diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo.Abp.OpenIddict.AspNetCore.Tests.csproj b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo.Abp.OpenIddict.AspNetCore.Tests.csproj index f4c6815fd6..003b78894c 100644 --- a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo.Abp.OpenIddict.AspNetCore.Tests.csproj +++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo.Abp.OpenIddict.AspNetCore.Tests.csproj @@ -5,6 +5,8 @@ net10.0 + true + true @@ -16,6 +18,10 @@ + + + + diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenEndpoint_Integration_Tests.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenEndpoint_Integration_Tests.cs new file mode 100644 index 0000000000..3f8fd1a855 --- /dev/null +++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenEndpoint_Integration_Tests.cs @@ -0,0 +1,69 @@ +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 +{ + private AbpAspNetCoreUnitOfWorkOptions Options => + ServiceProvider.GetRequiredService>().Value; + + private long? TokenCountAtResponseStart => + ServiceProvider.GetRequiredService().TokenCountAtResponseStart; + + private Task RequestTokenAsync() + { + return Client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary + { + ["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 its configured endpoint paths (including "/connect/token") 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); + } + + [Fact] + public void The_Configured_OpenIddict_Endpoint_Paths_Are_Opted_In() + { + // The opt-in list is derived from the configured server endpoints, so a non-"/connect" endpoint + // like "/device" is covered, and the custom "/my-custom/token" endpoint the test host registered + // is followed too - a hardcoded "/connect" prefix would miss both. + Options.CompleteUnitOfWorkOnResponseStartingUrls.ShouldContain("/connect/token"); + Options.CompleteUnitOfWorkOnResponseStartingUrls.ShouldContain("/device"); + Options.CompleteUnitOfWorkOnResponseStartingUrls.ShouldContain("/my-custom/token"); + } +} diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenIntegrationTestModule.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenIntegrationTestModule.cs new file mode 100644 index 0000000000..29e9c5a180 --- /dev/null +++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenIntegrationTestModule.cs @@ -0,0 +1,157 @@ +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(options => + { + options.AddDevelopmentEncryptionAndSigningCertificate = false; + }); + + PreConfigure(builder => + { + builder.AddEphemeralEncryptionKey(); + builder.AddEphemeralSigningKey(); + builder.UseAspNetCore().DisableTransportSecurityRequirement(); + }); + } + + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.AddSingleton(); + + // A remapped token endpoint, so the tests can prove the opt-in list is derived from the configured + // server endpoints (custom endpoints are followed) rather than a hardcoded "/connect" prefix. + Configure(options => + { + options.TokenEndpointUris.Add(new Uri("my-custom/token", UriKind.Relative)); + }); + + // The OpenIddict controllers (including the token endpoint) live in a referenced assembly. + context.Services.GetSingletonInstance() + .ApplicationParts.AddIfNotContains(typeof(AbpOpenIddictAspNetCoreModule).Assembly); + + using (var dbContext = new OpenIddictDbContext( + new DbContextOptionsBuilder().UseSqlite(ConnectionString).Options)) + { + dbContext.Database.EnsureCreated(); + } + + Configure(options => + { + options.ConnectionStrings.Default = ConnectionString; + }); + + Configure(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(); + var uowManager = ctx.RequestServices.GetRequiredService(); + using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); + var repository = ctx.RequestServices.GetRequiredService>(); + 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(); + using var uow = uowManager.Begin(); + + var applicationManager = scope.ServiceProvider.GetRequiredService(); + 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(); + } +} diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/Program.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/Program.cs new file mode 100644 index 0000000000..a2ac64130a --- /dev/null +++ b/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(); + +public partial class Program +{ +}