diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor index 1c90f954..d2dbfea9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor @@ -4,6 +4,7 @@ @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)] +@implements IAsyncDisposable @inject ICentralUiRepository CentralUiRepository @inject NavigationManager Nav @inject IJSRuntime JS @@ -286,6 +287,19 @@ private const string BrowserTimeModulePath = "./_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/browser-time.js"; private IJSObjectReference? _browserTimeModule; + /// + /// Releases the dynamically imported browser-time module so its JS-side object reference + /// is not retained for the life of the circuit. A disconnected circuit has already + /// dropped it, so that failure is expected and swallowed. + /// + public async ValueTask DisposeAsync() + { + if (_browserTimeModule is null) return; + try { await _browserTimeModule.DisposeAsync(); } + catch (JSDisconnectedException) { /* circuit already gone */ } + _browserTimeModule = null; + } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender) return; diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/ConfigurationAuditLogDisposeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/ConfigurationAuditLogDisposeTests.cs new file mode 100644 index 00000000..1df3eb62 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/ConfigurationAuditLogDisposeTests.cs @@ -0,0 +1,60 @@ +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.ConfigurationAuditLog; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Audit; + +/// Review 2026-10-21 07/M-1: the browser-time JS module must be disposed with the page. +public class ConfigurationAuditLogDisposeTests : BunitContext +{ + private sealed class StubAuth(AuthenticationState state) : AuthenticationStateProvider + { + public override Task GetAuthenticationStateAsync() => Task.FromResult(state); + } + + private (IJSObjectReference module, IRenderedComponent cut) Arrange(bool moduleThrowsOnDispose) + { + var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "u") }, "T"); + var auth = new StubAuth(new AuthenticationState(new ClaimsPrincipal(identity))); + Services.AddSingleton(auth); + Services.AddScoped(_ => new SiteScopeService(auth)); + Services.AddScoped(); + Services.AddSingleton(Substitute.For()); + + var module = Substitute.For(); + module.InvokeAsync("getTimezoneOffsetMinutes", Arg.Any()).Returns(new ValueTask(0)); + if (moduleThrowsOnDispose) + module.DisposeAsync().Returns(_ => ValueTask.FromException(new JSDisconnectedException("gone"))); + var js = Substitute.For(); + js.InvokeAsync("import", Arg.Any()) + .Returns(new ValueTask(module)); + Services.AddSingleton(js); + return (module, Render()); + } + + /// Disposing the page disposes the imported module. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Dispose_DisposesBrowserTimeModule() + { + var (module, cut) = Arrange(moduleThrowsOnDispose: false); + await cut.InvokeAsync(async () => await ((IAsyncDisposable)cut.Instance).DisposeAsync()); + await module.Received(1).DisposeAsync(); + } + + /// A disconnected circuit's dispose failure is swallowed. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Dispose_SwallowsJSDisconnected() + { + var (_, cut) = Arrange(moduleThrowsOnDispose: true); + await cut.InvokeAsync(async () => await ((IAsyncDisposable)cut.Instance).DisposeAsync()); + } +}