using System.Reflection; using System.Security.Claims; using Bunit; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.Security; using ZB.MOM.WW.ScadaBridge.Transport; using ZB.MOM.WW.ScadaBridge.Transport.Import; using NSubstitute; using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TransportImport; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design; /// /// Regression tests for register row 103 (the lesser instance in /// TransportImport). TryLoadAsync awaits /// , then used to publish the result into /// _session unconditionally. If the circuit was torn down inside that await /// (user navigates away mid-upload), Dispose would have already run, found /// _session still null, and released nothing — so the freshly-opened server-side /// BundleSession stayed registered, stranding one of /// TransportOptions.MaxConcurrentImportSessions (register row 43) for the full /// session TTL. The fix publishes the session and re-checks disposal under one lock /// (_sessionLock), mirroring DebugView's fix, so exactly one side /// (this method or Dispose) abandons it. /// public class TransportImportSessionDisposeRaceTests : BunitContext { /// /// A real -backed whose /// can be held open on demand, so the dispose-during-load /// window is deterministic rather than timing-dependent. Mirrors /// DebugViewConnectDisposeRaceTests.GatedDebugStreamService. /// private sealed class GatedImporter : IBundleImporter { private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly List _abandoned = new(); /// Gets the session store used by the test. public BundleSessionStore Store { get; } = new( Options.Create(new TransportOptions()), TimeProvider.System); /// Completes once has been entered. public Task Entered => _entered.Task; /// Session ids passed to , in call order. public IReadOnlyList Abandoned { get { lock (_abandoned) { return _abandoned.ToList(); } } } /// Releases the load that is blocked waiting for the test to let it finish. public void ReleaseLoad() => _gate.TrySetResult(); /// public async Task LoadAsync( Stream bundleStream, string? passphrase, CancellationToken ct = default, string? clientKey = null) { _entered.TrySetResult(); await _gate.Task; var session = new BundleSession { SessionId = Guid.NewGuid(), Manifest = new BundleManifest( BundleFormatVersion: 1, SchemaVersion: "1.0", CreatedAtUtc: DateTimeOffset.UtcNow, SourceEnvironment: "prod-cluster", ExportedBy: "bob", ScadaBridgeVersion: "1.0.0", ContentHash: "sha256:0000", Encryption: null, Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0), Contents: Array.Empty()), // Non-zero bytes so a zeroing assertion (if ever added) is meaningful. DecryptedContent = new byte[] { 0x11, 0x22, 0x33, 0x44 }, ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30), }; return Store.Open(session); } /// public Task PreviewAsync(Guid sessionId, CancellationToken ct = default) => throw new NotSupportedException("Not exercised by these tests."); /// public Task ApplyAsync( Guid sessionId, IReadOnlyList resolutions, string user, CancellationToken ct = default, BundleNameMap? nameMap = null) => throw new NotSupportedException("Not exercised by these tests."); /// public void Abandon(Guid sessionId) { lock (_abandoned) { _abandoned.Add(sessionId); } var session = Store.Get(sessionId); if (session is { DecryptedContent.Length: > 0 }) { Array.Clear(session.DecryptedContent, 0, session.DecryptedContent.Length); } Store.Remove(sessionId); } } private readonly GatedImporter _importer = new(); private readonly ISiteRepository _siteRepo = Substitute.For(); private const string TestClientIp = "203.0.113.7"; /// Initializes the test class and its fake session service. public TransportImportSessionDisposeRaceTests() { JSInterop.Mode = JSRuntimeMode.Loose; Services.AddSingleton(_importer); Services.AddSingleton(Substitute.For()); Services.AddSingleton(new CircuitClientAddressProvider(TestClientIp)); _siteRepo.GetAllSitesAsync(Arg.Any()).Returns(Array.Empty()); Services.AddSingleton(_siteRepo); Services.AddSingleton>( Options.Create(new TransportOptions { MaxBundleSizeMb = 10, MaxUnlockAttemptsPerSession = 3, })); var dbOptions = new DbContextOptionsBuilder() .UseSqlite("DataSource=:memory:") .ConfigureWarnings(w => w.Ignore(RelationalEventId.AmbientTransactionWarning)) .Options; var dbContext = new ScadaBridgeDbContext(dbOptions); dbContext.Database.OpenConnection(); dbContext.Database.EnsureCreated(); Services.AddSingleton(dbContext); var claims = new List { new(JwtTokenService.UsernameClaimType, "alice") }; var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(principal)); Services.AddAuthorizationCore(); Services.AddTestConfigurationMutations(); } private static void SetField(object obj, string name, object? value) => obj.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)! .SetValue(obj, value); private static T GetField(object obj, string name) => (T)obj.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)! .GetValue(obj)!; /// Stages a temp bundle file and points the wizard's _bundleTempPath at it. private static string StageBundleFile() { var dir = Path.Combine(Path.GetTempPath(), "scadabridge-transport-staging"); Directory.CreateDirectory(dir); var path = Path.Combine(dir, $"test-{Guid.NewGuid():N}.scadabundle"); File.WriteAllBytes(path, new byte[] { 0x01, 0x02, 0x03 }); return path; } /// /// Starts TryLoadAsync on the renderer's dispatcher (where it really runs) and /// hands back its task without awaiting it, so the test can act inside the load window. /// Mirrors DebugViewConnectDisposeRaceTests.StartConnectAsync. /// private static async Task StartTryLoadAsync(IRenderedComponent cut) { var method = typeof(TransportImportPage).GetMethod( "TryLoadAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; Task? task = null; await cut.InvokeAsync(() => { task = (Task)method.Invoke(cut.Instance, new object?[] { null })!; }); return task!; } /// Verifies that a component disposed mid-load abandons the session that was just opened. /// A task that represents the asynchronous operation. [Fact] public async Task TryLoadAsync_DisposedMidLoad_AbandonsTheJustOpenedSession() { var cut = Render(); SetField(cut.Instance, "_bundleTempPath", StageBundleFile()); var tryLoad = await StartTryLoadAsync(cut); await _importer.Entered.WaitAsync(TimeSpan.FromSeconds(5)); // The user navigates away while LoadAsync is still in flight. Dispose runs off // the renderer's thread, exactly as a circuit teardown can. cut.Instance.Dispose(); _importer.ReleaseLoad(); await tryLoad.WaitAsync(TimeSpan.FromSeconds(10)); Assert.Single(_importer.Abandoned); Assert.Null(GetField(cut.Instance, "_session")); // The store must not still hold the session either — Abandon actually released it. Assert.Null(_importer.Store.Get(_importer.Abandoned[0])); } /// Verifies that loading and then disposing abandons the session exactly once. /// A task that represents the asynchronous operation. [Fact] public async Task TryLoadAsync_ThenDispose_AbandonsTheSessionExactlyOnce() { var cut = Render(); SetField(cut.Instance, "_bundleTempPath", StageBundleFile()); // Normal path: the load completes while the component is very much alive. _importer.ReleaseLoad(); var tryLoad = await StartTryLoadAsync(cut); await tryLoad.WaitAsync(TimeSpan.FromSeconds(10)); Assert.Empty(_importer.Abandoned); Assert.NotNull(GetField(cut.Instance, "_session")); cut.Instance.Dispose(); Assert.Single(_importer.Abandoned); // A second Dispose (bUnit tears the component down again at test end) must not // abandon an already-released session a second time. cut.Instance.Dispose(); Assert.Single(_importer.Abandoned); } }