From a06ac397c0fc4be7dc88d5774f00bcdc85954731 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 20 Oct 2026 15:25:19 +0800 Subject: [PATCH] Skip auditing Blazor ComponentHub invocations --- .../framework/infrastructure/audit-logging.md | 6 +- docs/en/framework/real-time/signalr.md | 13 + docs/en/modules/audit-logging-pro.md | 2 - framework/Volo.Abp.slnx | 1 + .../AbpAspNetCoreComponentsServerModule.cs | 9 + .../SignalR/Auditing/AbpAuditHubFilter.cs | 10 +- .../AspNetCoreSignalRAuditLogContributor.cs | 4 + .../Auditing/AbpAspNetCoreAuditingOptions.cs | 10 +- .../Auditing/AbpAuditingMiddleware.cs | 12 + ....AspNetCore.Components.Server.Tests.csproj | 17 ++ ...pAspNetCoreComponentsServerModule_Tests.cs | 42 +++ ...AbpAspNetCoreComponentsServerTestModule.cs | 8 + ...NetCoreComponentsServerWebAppTestModule.cs | 16 ++ .../Volo.Abp.AspNetCore.SignalR.Tests.csproj | 1 + .../SignalR/AbpAspNetCoreSignalRTestModule.cs | 61 ++++- .../Auditing/AbpAuditHubFilter_Tests.cs | 242 ++++++++++++++++++ .../AbpSignalRAuditingMiddleware_Tests.cs | 54 ++++ .../SignalR/Auditing/TestApp/AuditedNote.cs | 19 ++ .../Auditing/TestApp/AuditedNoteService.cs | 50 ++++ .../TestApp/SignalRAuditingTestDbContext.cs | 14 + .../Auditing/TestApp/TestHubCallerContext.cs | 26 ++ .../SignalR/SampleHubs/IgnoredAuditingHub.cs | 8 + .../AbpAuditingMiddleware_HubRequest_Tests.cs | 146 +++++++++++ 23 files changed, 763 insertions(+), 8 deletions(-) create mode 100644 framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo.Abp.AspNetCore.Components.Server.Tests.csproj create mode 100644 framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerModule_Tests.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerTestModule.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerWebAppTestModule.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/AbpAuditHubFilter_Tests.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/AbpSignalRAuditingMiddleware_Tests.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/AuditedNote.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/AuditedNoteService.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/SignalRAuditingTestDbContext.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/TestHubCallerContext.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/SampleHubs/IgnoredAuditingHub.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware_HubRequest_Tests.cs diff --git a/docs/en/framework/infrastructure/audit-logging.md b/docs/en/framework/infrastructure/audit-logging.md index 8c13979c685..dc8564ec0ca 100644 --- a/docs/en/framework/infrastructure/audit-logging.md +++ b/docs/en/framework/infrastructure/audit-logging.md @@ -111,7 +111,9 @@ Configure(options => }); ```` -`IgnoredUrls` is the only option. It is a list of ignored URLs prefixes. In the preceding example, all URLs starting with `/products` will be ignored for audit logging. +`IgnoredUrls` is a list of ignored URLs prefixes. In the preceding example, all URLs starting with `/products` will be ignored for audit logging. + +`IgnoredHubTypes` is a list of ignored [SignalR](../real-time/signalr.md) hub types. The audited services called by these hubs are still audit logged. ## AbpAspNetCoreAuditingUrlOptions @@ -185,8 +187,6 @@ The action is still audit logged and the parameter name is still written, but it [Application service](../architecture/domain-driven-design/application-services.md) method calls also included into the audit log by default. You can use the `[DisableAuditing]` in service or method level. -> **Blazor Server limitation (Entity history):** In `Blazor Server` applications, entity change history is currently not guaranteed to be complete for every UI interaction. Blazor Server uses SignalR-based event handling, and under some flows the audit scope/action tracking may not align with `DbContext.SaveChanges`, which can cause missing or partial entity change records. This is a known platform-level limitation and not a regular configuration issue. See [#11682](https://github.com/abpframework/abp/issues/11682) for related discussions. - #### Enable/Disable for Other Services Action audit logging can be enabled for any type of class (registered to and resolved from the [dependency injection](../fundamentals/dependency-injection.md)) while it is only enabled for the controllers and the application services by default. diff --git a/docs/en/framework/real-time/signalr.md b/docs/en/framework/real-time/signalr.md index 4a3ffe69fcf..358b051bbb7 100644 --- a/docs/en/framework/real-time/signalr.md +++ b/docs/en/framework/real-time/signalr.md @@ -234,6 +234,19 @@ Configure(options => }); ``` +### Auditing + +Use `AbpAspNetCoreAuditingOptions.IgnoredHubTypes` to ignore a hub (and the hubs derived from it) for [audit logging](../infrastructure/audit-logging.md): + +```csharp +Configure(options => +{ + options.IgnoredHubTypes.Add(typeof(MessagingHub)); +}); +``` + +The audited services called by an ignored hub are still audit logged. The hub of Blazor Server (and the interactive server render mode of Blazor Web App) is ignored by default. + ### UserIdProvider ABP implements SignalR's `IUserIdProvider` interface to provide the current user id from the `ICurrentUser` service of the ABP (see [the current user service](../infrastructure/current-user.md)), so it will be integrated to the authentication system of your application. The implementing class is the `AbpSignalRUserIdProvider`, if you want to change/override it. diff --git a/docs/en/modules/audit-logging-pro.md b/docs/en/modules/audit-logging-pro.md index 1e7ab562dc3..b72c48a92c8 100644 --- a/docs/en/modules/audit-logging-pro.md +++ b/docs/en/modules/audit-logging-pro.md @@ -73,8 +73,6 @@ You can export audit logs to Excel by clicking the "Export to Excel" button in t Entity changes tab is used to list, view and filter entity change logs. -> **Blazor Server note:** Entity change history can be missing or incomplete in some `Blazor Server` scenarios due to known SignalR/event-pipeline limitations. See [Audit Logging](../framework/infrastructure/audit-logging.md) and [#11682](https://github.com/abpframework/abp/issues/11682). - ![audit-logging-module-entity-changes-list-page](../images/audit-logging-module-entity-changes-list-page.png) diff --git a/framework/Volo.Abp.slnx b/framework/Volo.Abp.slnx index 526bfebe0db..60d0a416b67 100644 --- a/framework/Volo.Abp.slnx +++ b/framework/Volo.Abp.slnx @@ -187,6 +187,7 @@ + diff --git a/framework/src/Volo.Abp.AspNetCore.Components.Server/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerModule.cs b/framework/src/Volo.Abp.AspNetCore.Components.Server/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerModule.cs index 74b51930664..55a681a8b86 100644 --- a/framework/src/Volo.Abp.AspNetCore.Components.Server/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerModule.cs +++ b/framework/src/Volo.Abp.AspNetCore.Components.Server/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerModule.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components.Server; using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -52,6 +53,14 @@ public override void ConfigureServices(ServiceConfigurationContext context) Configure(options => { options.IgnoredUrls.AddIfNotContains("/_blazor"); + + // ComponentHub returns before component event handlers complete, so a hub level audit scope + // would be disposed before the application services add their actions and entity changes to it. + var componentHubType = typeof(CircuitOptions).Assembly.GetType("Microsoft.AspNetCore.Components.Server.ComponentHub"); + if (componentHubType != null) + { + options.IgnoredHubTypes.AddIfNotContains(componentHubType); + } }); if (!context.Services.ExecutePreConfiguredActions().IsBlazorWebApp) diff --git a/framework/src/Volo.Abp.AspNetCore.SignalR/Volo/Abp/AspNetCore/SignalR/Auditing/AbpAuditHubFilter.cs b/framework/src/Volo.Abp.AspNetCore.SignalR/Volo/Abp/AspNetCore/SignalR/Auditing/AbpAuditHubFilter.cs index 7d3c21f067b..7ac2e12fd04 100644 --- a/framework/src/Volo.Abp.AspNetCore.SignalR/Volo/Abp/AspNetCore/SignalR/Auditing/AbpAuditHubFilter.cs +++ b/framework/src/Volo.Abp.AspNetCore.SignalR/Volo/Abp/AspNetCore/SignalR/Auditing/AbpAuditHubFilter.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Volo.Abp.AspNetCore.Auditing; using Volo.Abp.Auditing; using Volo.Abp.Uow; using Volo.Abp.Users; @@ -17,7 +18,7 @@ public class AbpAuditHubFilter : IHubFilter public virtual async ValueTask InvokeMethodAsync(HubInvocationContext invocationContext, Func> next) { var options = invocationContext.ServiceProvider.GetRequiredService>().Value; - if (!options.IsEnabled) + if (!options.IsEnabled || IsIgnoredHub(invocationContext)) { return await next(invocationContext); } @@ -76,6 +77,13 @@ public class AbpAuditHubFilter : IHubFilter } } + protected virtual bool IsIgnoredHub(HubInvocationContext invocationContext) + { + var options = invocationContext.ServiceProvider.GetRequiredService>().Value; + var hubType = invocationContext.Hub.GetType(); + return options.IgnoredHubTypes.Any(ignoredHubType => ignoredHubType.IsAssignableFrom(hubType)); + } + private async Task ShouldWriteAuditLogAsync(AuditLogInfo auditLogInfo, IServiceProvider serviceProvider, bool hasError) { var options = serviceProvider.GetRequiredService>().Value; diff --git a/framework/src/Volo.Abp.AspNetCore.SignalR/Volo/Abp/AspNetCore/SignalR/Auditing/AspNetCoreSignalRAuditLogContributor.cs b/framework/src/Volo.Abp.AspNetCore.SignalR/Volo/Abp/AspNetCore/SignalR/Auditing/AspNetCoreSignalRAuditLogContributor.cs index a4b977de2f1..f39b50c6065 100644 --- a/framework/src/Volo.Abp.AspNetCore.SignalR/Volo/Abp/AspNetCore/SignalR/Auditing/AspNetCoreSignalRAuditLogContributor.cs +++ b/framework/src/Volo.Abp.AspNetCore.SignalR/Volo/Abp/AspNetCore/SignalR/Auditing/AspNetCoreSignalRAuditLogContributor.cs @@ -25,6 +25,10 @@ public override void PreContribute(AuditLogContributionContext context) return; } + // The HTTP method of a non WebSocket transport (like the GET request of Server-Sent Events) belongs to the + // connection, not to the hub method call. Otherwise, the audit logs of the hub method calls are treated as GET requests. + context.AuditInfo.HttpMethod = null; + var clientInfoProvider = context.ServiceProvider.GetRequiredService(); if (context.AuditInfo.ClientIpAddress == null) { diff --git a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAspNetCoreAuditingOptions.cs b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAspNetCoreAuditingOptions.cs index 693945e9035..ad5d757f1ce 100644 --- a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAspNetCoreAuditingOptions.cs +++ b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAspNetCoreAuditingOptions.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; namespace Volo.Abp.AspNetCore.Auditing; @@ -11,4 +12,11 @@ public class AbpAspNetCoreAuditingOptions /// starting with an ignored URL. /// public List IgnoredUrls { get; } = new(); + + /// + /// SignalR hub types (including the derived types) that no audit log scope is created for, + /// neither for their requests by the nor for their method calls by the ABP SignalR integration. + /// The audited services called by these hubs still create and save their own audit logs. + /// + public List IgnoredHubTypes { get; } = new(); } diff --git a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs index 112b3be61a5..5e6334bda09 100644 --- a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs +++ b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Options; using Volo.Abp.AspNetCore.Middleware; using Volo.Abp.Auditing; @@ -94,6 +95,17 @@ public async override Task InvokeAsync(HttpContext context, RequestDelegate next } } + protected async override Task ShouldSkipAsync(HttpContext context, RequestDelegate next) + { + var hubType = context.GetEndpoint()?.Metadata?.GetMetadata()?.HubType; + if (hubType != null && AspNetCoreAuditingOptions.IgnoredHubTypes.Any(ignoredHubType => ignoredHubType.IsAssignableFrom(hubType))) + { + return true; + } + + return await base.ShouldSkipAsync(context, next); + } + private bool IsIgnoredUrl(HttpContext context) { if (context.Request.Path.Value == null) diff --git a/framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo.Abp.AspNetCore.Components.Server.Tests.csproj b/framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo.Abp.AspNetCore.Components.Server.Tests.csproj new file mode 100644 index 00000000000..eb261df1843 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo.Abp.AspNetCore.Components.Server.Tests.csproj @@ -0,0 +1,17 @@ + + + + + + net10.0 + + enable + + + + + + + + + diff --git a/framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerModule_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerModule_Tests.cs new file mode 100644 index 00000000000..d92c0fad984 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerModule_Tests.cs @@ -0,0 +1,42 @@ +using System.Linq; +using Microsoft.Extensions.Options; +using Shouldly; +using Volo.Abp.AspNetCore.Auditing; +using Volo.Abp.Modularity; +using Volo.Abp.Testing; +using Xunit; + +namespace Volo.Abp.AspNetCore.Components.Server; + +public abstract class AbpAspNetCoreComponentsServerModule_Tests : AbpIntegratedTest + where TStartupModule : IAbpModule +{ + private const string ComponentHubTypeFullName = "Microsoft.AspNetCore.Components.Server.ComponentHub"; + + private readonly AbpAspNetCoreAuditingOptions _auditingOptions; + + protected AbpAspNetCoreComponentsServerModule_Tests() + { + _auditingOptions = GetRequiredService>().Value; + } + + [Fact] + public void Should_Only_Ignore_ComponentHub_For_Hub_Auditing() + { + _auditingOptions.IgnoredHubTypes.Single().FullName.ShouldBe(ComponentHubTypeFullName); + } + + [Fact] + public void Should_Ignore_Blazor_Urls_For_Auditing() + { + _auditingOptions.IgnoredUrls.ShouldContain("/_blazor"); + } +} + +public class AbpAspNetCoreComponentsServerModule_BlazorServer_Tests : AbpAspNetCoreComponentsServerModule_Tests +{ +} + +public class AbpAspNetCoreComponentsServerModule_BlazorWebApp_Tests : AbpAspNetCoreComponentsServerModule_Tests +{ +} diff --git a/framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerTestModule.cs b/framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerTestModule.cs new file mode 100644 index 00000000000..2905b34e5e0 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerTestModule.cs @@ -0,0 +1,8 @@ +using Volo.Abp.Modularity; + +namespace Volo.Abp.AspNetCore.Components.Server; + +[DependsOn(typeof(AbpAspNetCoreComponentsServerModule))] +public class AbpAspNetCoreComponentsServerTestModule : AbpModule +{ +} diff --git a/framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerWebAppTestModule.cs b/framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerWebAppTestModule.cs new file mode 100644 index 00000000000..cd385e05de9 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Components.Server.Tests/Volo/Abp/AspNetCore/Components/Server/AbpAspNetCoreComponentsServerWebAppTestModule.cs @@ -0,0 +1,16 @@ +using Volo.Abp.AspNetCore.Components.Web; +using Volo.Abp.Modularity; + +namespace Volo.Abp.AspNetCore.Components.Server; + +[DependsOn(typeof(AbpAspNetCoreComponentsServerModule))] +public class AbpAspNetCoreComponentsServerWebAppTestModule : AbpModule +{ + public override void PreConfigureServices(ServiceConfigurationContext context) + { + PreConfigure(options => + { + options.IsBlazorWebApp = true; + }); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo.Abp.AspNetCore.SignalR.Tests.csproj b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo.Abp.AspNetCore.SignalR.Tests.csproj index e47ff0d5027..9aca6b56648 100644 --- a/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo.Abp.AspNetCore.SignalR.Tests.csproj +++ b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo.Abp.AspNetCore.SignalR.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/AbpAspNetCoreSignalRTestModule.cs b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/AbpAspNetCoreSignalRTestModule.cs index 9fdd54e3e8c..4eb3d44f2a5 100644 --- a/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/AbpAspNetCoreSignalRTestModule.cs +++ b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/AbpAspNetCoreSignalRTestModule.cs @@ -1,9 +1,16 @@ using System; using Microsoft.AspNetCore.Builder; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.AspNetCore.Auditing; +using Volo.Abp.AspNetCore.SignalR.Auditing.TestApp; using Volo.Abp.AspNetCore.SignalR.SampleHubs; using Volo.Abp.AspNetCore.TestBase; +using Volo.Abp.Auditing; using Volo.Abp.Autofac; +using Volo.Abp.Data; +using Volo.Abp.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore.Sqlite; using Volo.Abp.Modularity; using Volo.Abp.Uow; @@ -12,24 +19,75 @@ namespace Volo.Abp.AspNetCore.SignalR; [DependsOn( typeof(AbpAspNetCoreSignalRModule), typeof(AbpAspNetCoreTestBaseModule), - typeof(AbpAutofacModule) + typeof(AbpAutofacModule), + typeof(AbpEntityFrameworkCoreSqliteModule) )] public class AbpAspNetCoreSignalRTestModule : AbpModule { public const string UnitOfWorkReservedHeaderName = "X-Unit-Of-Work-Reserved"; + public const string AuditScopeStartedHeaderName = "X-Audit-Scope-Started"; + public static Exception UseConfiguredEndpointsException { get; set; } + private AbpUnitTestSqliteDatabase _database; + + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.AddAbpDbContext(options => + { + options.AddDefaultRepositories(includeAllEntities: true); + }); + + _database = new AbpUnitTestSqliteDatabase(); + _database.CreateTables(new SignalRAuditingTestDbContext( + new DbContextOptionsBuilder() + .UseSqlite(_database.ConnectionString) + .AddAbpDbContextOptionsExtension() + .Options)); + + Configure(options => + { + options.ConnectionStrings.Default = _database.ConnectionString; + }); + + Configure(options => + { + options.Configure(configurationContext => + { + configurationContext.UseSqlite(); + }); + }); + + Configure(options => + { + options.IgnoredHubTypes.Add(typeof(IgnoredAuditingHub)); + }); + + Configure(options => + { + options.EntityHistorySelectors.Add(new NamedTypeSelector(nameof(AuditedNote), type => type == typeof(AuditedNote))); + }); + } + + public override void OnApplicationShutdown(ApplicationShutdownContext context) + { + _database?.Dispose(); + } + public override void OnApplicationInitialization(ApplicationInitializationContext context) { var app = context.GetApplicationBuilder(); app.UseRouting(); app.UseUnitOfWork(); + app.UseAuditing(); app.Use(async (httpContext, next) => { httpContext.Response.Headers[UnitOfWorkReservedHeaderName] = (httpContext.RequestServices.GetRequiredService().UnitOfWork?.IsReserved == true).ToString(); + httpContext.Response.Headers[AuditScopeStartedHeaderName] = + (httpContext.RequestServices.GetRequiredService().Current != null).ToString(); await next(); }); @@ -40,6 +98,7 @@ public override void OnApplicationInitialization(ApplicationInitializationContex app.UseConfiguredEndpoints(endpoints => { endpoints.MapHub("/manually-mapped-hub"); + endpoints.MapHub("/ignored-auditing-hub"); }); } catch (Exception e) diff --git a/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/AbpAuditHubFilter_Tests.cs b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/AbpAuditHubFilter_Tests.cs new file mode 100644 index 00000000000..02b40f73577 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/AbpAuditHubFilter_Tests.cs @@ -0,0 +1,242 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NSubstitute; +using Shouldly; +using Volo.Abp.AspNetCore.Auditing; +using Volo.Abp.AspNetCore.SignalR.Auditing.TestApp; +using Volo.Abp.Auditing; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.AspNetCore.SignalR.Auditing; + +[Collection("AbpAspNetCoreSignalR")] +public class AbpAuditHubFilter_Tests : AbpAspNetCoreTestBase +{ + private IAuditingStore _auditingStore; + + public AbpAuditHubFilter_Tests() + { + _auditingStore = GetRequiredService(); + } + + protected override void ConfigureServices(IServiceCollection services) + { + services.Configure(options => + { + options.IgnoredHubTypes.Add(typeof(IgnoredAuditHub)); + }); + + _auditingStore = Substitute.For(); + services.Replace(ServiceDescriptor.Singleton(_auditingStore)); + } + + [Fact] + public async Task Should_Begin_An_Audit_Scope_For_Regular_Hubs() + { + (await HasAuditScopeInHubMethodAsync(new RegularAuditHub())).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Not_Begin_An_Audit_Scope_For_Ignored_Hubs() + { + (await HasAuditScopeInHubMethodAsync(new IgnoredAuditHub())).ShouldBeFalse(); + } + + [Fact] + public async Task Should_Not_Begin_An_Audit_Scope_For_Hubs_Derived_From_Ignored_Hubs() + { + (await HasAuditScopeInHubMethodAsync(new DerivedIgnoredAuditHub())).ShouldBeFalse(); + } + + [Fact] + public async Task Should_Save_The_Hub_Audit_Log_After_A_Regular_Hub_Method_Completes() + { + var noteId = await CreateNoteAsync("old"); + using var scope = GetRequiredService().CreateScope(); + var noteService = scope.ServiceProvider.GetRequiredService(); + + var invocationTask = InvokeHubMethodAsync(scope.ServiceProvider, new RegularAuditHub(), async () => + { + await noteService.UpdateTextAsync(noteId, "new"); + }); + + await Task.Delay(100); + invocationTask.IsCompleted.ShouldBeFalse(); + await _auditingStore.DidNotReceive().SaveAsync(Arg.Any()); + + GetRequiredService().Complete(); + await invocationTask; + + await ShouldHaveSavedTheNoteUpdateAsync(noteId); + } + + [Fact] + public async Task Should_Save_The_Audit_Log_Of_A_Service_That_Completes_After_An_Ignored_Hub_Method_Returns() + { + var noteId = await CreateNoteAsync("old"); + using var scope = GetRequiredService().CreateScope(); + + var serviceTask = await StartServiceInIgnoredHubMethodAsync(scope.ServiceProvider, noteId); + await _auditingStore.DidNotReceive().SaveAsync(Arg.Any()); + + GetRequiredService().Complete(); + await serviceTask; + + await ShouldHaveSavedTheNoteUpdateAsync(noteId); + } + + [Fact] + public async Task Should_Save_The_Entity_Changes_Saved_By_The_Unit_Of_Work_Of_A_Service_That_Completes_After_An_Ignored_Hub_Method_Returns() + { + var noteId = await CreateNoteAsync("old"); + using var scope = GetRequiredService().CreateScope(); + + var serviceTask = await StartServiceInIgnoredHubMethodAsync( + scope.ServiceProvider, + noteService => noteService.UpdateTextWithoutSavingAsync(noteId, "new")); + await _auditingStore.DidNotReceive().SaveAsync(Arg.Any()); + + GetRequiredService().Complete(); + await serviceTask; + + await ShouldHaveSavedTheNoteUpdateAsync(noteId, nameof(AuditedNoteService.UpdateTextWithoutSavingAsync)); + (await GetRequiredService>().GetAsync(noteId)).Text.ShouldBe("new"); + } + + [Fact] + public async Task Should_Save_The_Audit_Log_Of_A_Service_Called_By_An_Ignored_Hub_Over_A_Non_WebSocket_Transport() + { + var noteId = await CreateNoteAsync("old"); + using var scope = GetRequiredService().CreateScope(); + + // Server-Sent Events keep the connection on a single GET request. + var httpContextAccessor = scope.ServiceProvider.GetRequiredService(); + var connectionRequest = new DefaultHttpContext { RequestServices = scope.ServiceProvider }; + connectionRequest.Request.Method = HttpMethods.Get; + connectionRequest.Request.Path = "/signalr-hubs/ignored"; + httpContextAccessor.HttpContext = connectionRequest; + + try + { + var serviceTask = await StartServiceInIgnoredHubMethodAsync(scope.ServiceProvider, noteId); + + GetRequiredService().Complete(); + await serviceTask; + } + finally + { + httpContextAccessor.HttpContext = null; + } + + await ShouldHaveSavedTheNoteUpdateAsync(noteId); + await _auditingStore.Received(1).SaveAsync(Arg.Is(x => x.HttpMethod == null)); + } + + private Task StartServiceInIgnoredHubMethodAsync(IServiceProvider serviceProvider, Guid noteId) + { + return StartServiceInIgnoredHubMethodAsync(serviceProvider, noteService => noteService.UpdateTextAsync(noteId, "new")); + } + + private async Task StartServiceInIgnoredHubMethodAsync(IServiceProvider serviceProvider, Func serviceCall) + { + var noteService = serviceProvider.GetRequiredService(); + + Task serviceTask = null; + await InvokeHubMethodAsync(serviceProvider, new IgnoredAuditHub(), () => + { + // Like Blazor's ComponentHub, return before the started work completes. + serviceTask = serviceCall(noteService); + return Task.CompletedTask; + }); + + return serviceTask; + } + + private async Task CreateNoteAsync(string text) + { + var noteId = Guid.NewGuid(); + using (var uow = GetRequiredService().Begin(requiresNew: true)) + { + await GetRequiredService>().InsertAsync(new AuditedNote(noteId, text)); + await uow.CompleteAsync(); + } + + _auditingStore.ClearReceivedCalls(); + return noteId; + } + + private async Task ShouldHaveSavedTheNoteUpdateAsync(Guid noteId, string methodName = nameof(AuditedNoteService.UpdateTextAsync)) + { + await _auditingStore.Received(1).SaveAsync(Arg.Any()); + await _auditingStore.Received(1).SaveAsync(Arg.Is(x => + x.Actions.Count == 1 && + x.Actions[0].MethodName == methodName && + x.EntityChanges.Count == 1 && + x.EntityChanges[0].ChangeType == EntityChangeType.Updated && + x.EntityChanges[0].EntityTypeFullName == typeof(AuditedNote).FullName && + x.EntityChanges[0].EntityId == noteId.ToString() && + x.EntityChanges[0].PropertyChanges.Any(p => + p.PropertyName == nameof(AuditedNote.Text) && + p.OriginalValue == "\"old\"" && + p.NewValue == "\"new\""))); + } + + private async Task HasAuditScopeInHubMethodAsync(Hub hub) + { + using var scope = GetRequiredService().CreateScope(); + var auditingManager = scope.ServiceProvider.GetRequiredService(); + + var hasAuditScope = false; + await InvokeHubMethodAsync(scope.ServiceProvider, hub, () => + { + hasAuditScope = auditingManager.Current != null; + return Task.CompletedTask; + }); + + return hasAuditScope; + } + + private static async Task InvokeHubMethodAsync(IServiceProvider serviceProvider, Hub hub, Func hubMethod) + { + var invocationContext = new HubInvocationContext( + new TestHubCallerContext(), + serviceProvider, + hub, + typeof(RegularAuditHub).GetMethod(nameof(RegularAuditHub.Ping)), + Array.Empty()); + + // AbpAspNetCoreSignalRModule also registers the hub context accessor filter before the audit filter. + await new AbpHubContextAccessorHubFilter().InvokeMethodAsync(invocationContext, context => + new AbpAuditHubFilter().InvokeMethodAsync(context, async _ => + { + await hubMethod(); + return null; + })); + } + + [DisableConventionalRegistration] + public class RegularAuditHub : Hub + { + public void Ping() + { + } + } + + [DisableConventionalRegistration] + public class IgnoredAuditHub : Hub + { + } + + [DisableConventionalRegistration] + public class DerivedIgnoredAuditHub : IgnoredAuditHub + { + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/AbpSignalRAuditingMiddleware_Tests.cs b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/AbpSignalRAuditingMiddleware_Tests.cs new file mode 100644 index 00000000000..7d2f41b63be --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/AbpSignalRAuditingMiddleware_Tests.cs @@ -0,0 +1,54 @@ +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Shouldly; +using Xunit; + +namespace Volo.Abp.AspNetCore.SignalR.Auditing; + +[Collection("AbpAspNetCoreSignalR")] +public class AbpSignalRAuditingMiddleware_Tests : AbpAspNetCoreTestBase +{ + [Theory] + [InlineData("/signalr-hubs/regular-abp/negotiate?negotiateVersion=1")] + [InlineData("/custom-route-hub/negotiate?negotiateVersion=1")] + public async Task Should_Not_Begin_An_Audit_Scope_For_The_Requests_Of_The_Auto_Mapped_Hubs(string url) + { + var response = await Client.PostAsync(url, null); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + IsAuditScopeStarted(response).ShouldBeFalse(); + } + + [Fact] + public async Task Should_Not_Begin_An_Audit_Scope_For_The_Requests_Of_The_Ignored_Hub_Types() + { + var response = await Client.PostAsync("/ignored-auditing-hub/negotiate?negotiateVersion=1", null); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + IsAuditScopeStarted(response).ShouldBeFalse(); + } + + [Fact] + public async Task Should_Begin_An_Audit_Scope_For_The_Requests_Of_The_Other_Manually_Mapped_Hubs() + { + var response = await Client.PostAsync("/manually-mapped-hub/negotiate?negotiateVersion=1", null); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + IsAuditScopeStarted(response).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Begin_An_Audit_Scope_For_The_Other_Requests() + { + var response = await Client.GetAsync("/not-a-hub"); + + IsAuditScopeStarted(response).ShouldBeTrue(); + } + + private static bool IsAuditScopeStarted(HttpResponseMessage response) + { + return bool.Parse(response.Headers.GetValues(AbpAspNetCoreSignalRTestModule.AuditScopeStartedHeaderName).Single()); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/AuditedNote.cs b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/AuditedNote.cs new file mode 100644 index 00000000000..fd23bd91df0 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/AuditedNote.cs @@ -0,0 +1,19 @@ +using System; +using Volo.Abp.Domain.Entities; + +namespace Volo.Abp.AspNetCore.SignalR.Auditing.TestApp; + +public class AuditedNote : Entity +{ + public string Text { get; set; } + + protected AuditedNote() + { + } + + public AuditedNote(Guid id, string text) + : base(id) + { + Text = text; + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/AuditedNoteService.cs b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/AuditedNoteService.cs new file mode 100644 index 00000000000..0ffad7b018c --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/AuditedNoteService.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading.Tasks; +using Volo.Abp.Auditing; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Uow; + +namespace Volo.Abp.AspNetCore.SignalR.Auditing.TestApp; + +[Audited] +public class AuditedNoteService : ITransientDependency +{ + private readonly IRepository _noteRepository; + private readonly AuditedWorkSignal _signal; + + public AuditedNoteService(IRepository noteRepository, AuditedWorkSignal signal) + { + _noteRepository = noteRepository; + _signal = signal; + } + + [UnitOfWork] + public virtual async Task UpdateTextAsync(Guid id, string text) + { + await _signal.Task; + + var note = await _noteRepository.GetAsync(id); + note.Text = text; + await _noteRepository.UpdateAsync(note, autoSave: true); + } + + [UnitOfWork] + public virtual async Task UpdateTextWithoutSavingAsync(Guid id, string text) + { + await _signal.Task; + + // The change is saved when the unit of work completes. + var note = await _noteRepository.GetAsync(id); + note.Text = text; + } +} + +public class AuditedWorkSignal : ISingletonDependency +{ + private readonly TaskCompletionSource _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Task => _completionSource.Task; + + public void Complete() => _completionSource.TrySetResult(); +} diff --git a/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/SignalRAuditingTestDbContext.cs b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/SignalRAuditingTestDbContext.cs new file mode 100644 index 00000000000..5b6c98167c1 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/SignalRAuditingTestDbContext.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +namespace Volo.Abp.AspNetCore.SignalR.Auditing.TestApp; + +public class SignalRAuditingTestDbContext : AbpDbContext +{ + public DbSet AuditedNotes { get; set; } + + public SignalRAuditingTestDbContext(DbContextOptions options) + : base(options) + { + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/TestHubCallerContext.cs b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/TestHubCallerContext.cs new file mode 100644 index 00000000000..e307ca3b487 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/Auditing/TestApp/TestHubCallerContext.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.SignalR; + +namespace Volo.Abp.AspNetCore.SignalR.Auditing.TestApp; + +public class TestHubCallerContext : HubCallerContext +{ + public override string ConnectionId => "test-connection"; + + public override string UserIdentifier => null; + + public override ClaimsPrincipal User => null; + + public override IDictionary Items { get; } = new Dictionary(); + + public override IFeatureCollection Features { get; } = new FeatureCollection(); + + public override CancellationToken ConnectionAborted => CancellationToken.None; + + public override void Abort() + { + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/SampleHubs/IgnoredAuditingHub.cs b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/SampleHubs/IgnoredAuditingHub.cs new file mode 100644 index 00000000000..47ee41c85c1 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/SampleHubs/IgnoredAuditingHub.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.SignalR; + +namespace Volo.Abp.AspNetCore.SignalR.SampleHubs; + +[DisableAutoHubMap] +public class IgnoredAuditingHub : Hub +{ +} diff --git a/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware_HubRequest_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware_HubRequest_Tests.cs new file mode 100644 index 00000000000..feb5c9fa702 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Auditing/AbpAuditingMiddleware_HubRequest_Tests.cs @@ -0,0 +1,146 @@ +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Volo.Abp.Auditing; +using Volo.Abp.Modularity; +using Xunit; + +namespace Volo.Abp.AspNetCore.Auditing; + +public class AbpAuditingMiddleware_HubRequest_Tests +{ + [Fact] + public async Task Should_Begin_An_Audit_Scope_For_Hub_Requests_By_Default() + { + (await IsAuditScopeStartedForHubRequestAsync()).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Not_Begin_An_Audit_Scope_For_The_Requests_Of_An_Ignored_Hub_Type() + { + (await IsAuditScopeStartedForHubRequestAsync()).ShouldBeFalse(); + } + + [Fact] + public async Task Should_Not_Begin_An_Audit_Scope_For_The_Requests_Of_A_Hub_Derived_From_An_Ignored_Hub_Type() + { + (await IsAuditScopeStartedForHubRequestAsync()).ShouldBeFalse(); + } + + [Fact] + public async Task Should_Begin_An_Audit_Scope_For_The_Requests_Of_The_Other_Hubs() + { + (await IsAuditScopeStartedForHubRequestAsync()).ShouldBeTrue(); + } + + private static async Task IsAuditScopeStartedForHubRequestAsync() + where TModule : IAbpModule + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Host.UseAutofac(); + await builder.AddApplicationAsync(); + + await using var app = builder.Build(); + await app.InitializeApplicationAsync(); + await app.StartAsync(); + + try + { + using var client = ((IHost)app).GetTestClient(); + var response = await client.PostAsync("/native-hub/negotiate?negotiateVersion=1", null); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + return bool.Parse(response.Headers.GetValues(NativeSignalRAuditingTestModule.AuditScopeStartedHeaderName).Single()); + } + finally + { + await app.StopAsync(); + } + } + + public class NativeTestHubBase : Hub + { + } + + public class NativeTestHub : NativeTestHubBase + { + } + + public class OtherNativeTestHub : Hub + { + } + + [DependsOn(typeof(AbpAspNetCoreTestModule))] + public class NativeSignalRAuditingTestModule : AbpModule + { + public const string AuditScopeStartedHeaderName = "X-Audit-Scope-Started"; + + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.AddSignalR(); + } + + public override void OnApplicationInitialization(ApplicationInitializationContext context) + { + var app = context.GetApplicationBuilder(); + + app.UseRouting(); + app.UseAuditing(); + app.Use(async (httpContext, next) => + { + httpContext.Response.Headers[AuditScopeStartedHeaderName] = + (httpContext.RequestServices.GetRequiredService().Current != null).ToString(); + + await next(); + }); + app.UseEndpoints(endpoints => + { + endpoints.MapHub("/native-hub"); + }); + } + } + + [DependsOn(typeof(NativeSignalRAuditingTestModule))] + public class IgnoredHubTypeTestModule : AbpModule + { + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.IgnoredHubTypes.Add(typeof(NativeTestHub)); + }); + } + } + + [DependsOn(typeof(NativeSignalRAuditingTestModule))] + public class IgnoredBaseHubTypeTestModule : AbpModule + { + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.IgnoredHubTypes.Add(typeof(NativeTestHubBase)); + }); + } + } + + [DependsOn(typeof(NativeSignalRAuditingTestModule))] + public class IgnoredOtherHubTypeTestModule : AbpModule + { + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.IgnoredHubTypes.Add(typeof(OtherNativeTestHub)); + }); + } + } +}