using System.Runtime.CompilerServices; using System.Security.Claims; using System.Threading.Channels; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web.HtmlRendering; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages; using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; /// /// Covers the ACL gate on the session-details page's in-process subscribe seam. /// /// /// /// The 2026-08 in-process feed refactor gave the dashboard a second way to subscribe to a /// session's events — , used by this page — so /// gating the hub join alone would leave the page as an ungated path to the same feed. The /// denial assertion here is the one that proves the second seam is closed: it asserts that /// is never called, not merely that the /// panel renders differently. /// /// /// Rendered through the framework's static , the same idiom /// SecretsNavRenderTests uses — no component-testing package, because the assertions are /// about the emitted markup and the calls the lifecycle makes, not about interactivity. /// /// public sealed class SessionDetailsPageEventAclTests { private const string SessionId = "session-1"; // Matched without the trailing possessive so the assertion does not depend on how the // renderer escapes the apostrophe. private const string DeniedMessage = "Not authorized for this session"; private const string WaitingMarker = "Waiting for events."; /// A denied caller gets the message and no subscription is opened. /// A task that represents the asynchronous operation. [Fact] public async Task Page_WhenAclDenies_RendersMessageAndDoesNotSubscribe() { RecordingEventSubscriber subscriber = new(); string html = await RenderAsync(subscriber, allow: false); Assert.Contains(DeniedMessage, html, StringComparison.Ordinal); Assert.DoesNotContain(WaitingMarker, html, StringComparison.Ordinal); Assert.Empty(subscriber.SubscribedSessionIds); } /// /// The control for the denial above: an allowed caller subscribes and sees the ordinary /// waiting state. Without this, a page that failed to render its events panel at all would /// satisfy the "no subscription" assertion and the suite would report a working gate over a /// broken panel. /// /// A task that represents the asynchronous operation. [Fact] public async Task Page_WhenAclAllows_SubscribesAndRendersWaitingState() { RecordingEventSubscriber subscriber = new(); string html = await RenderAsync(subscriber, allow: true); Assert.Equal([SessionId], subscriber.SubscribedSessionIds); Assert.Contains(WaitingMarker, html, StringComparison.Ordinal); Assert.DoesNotContain(DeniedMessage, html, StringComparison.Ordinal); } /// /// The re-entrancy guard on AttachEventsAsync: a rapid A -> B navigation must leave /// exactly one live subscription, not two. /// /// /// /// Gating the ACL made attach asynchronous — it awaits the authentication state — and that /// await is a suspension point the synchronous version did not have. The interleaving this /// test forces is the one that window admits: A's attach parks on the auth state, B's whole /// parameter set runs to completion behind it, and only then does A resume. A now reads /// SessionId as B's and, ungurarded, subscribes to B a SECOND time — overwriting the /// fields holding B's first subscription, which is then unreachable: never disposed, its /// EventsHubViewerRegistry entry never released (so the mirror keeps cloning events /// for it), its pump never cancelled. /// /// /// The assertion is deliberately about subscription COUNT and disposal rather than about the /// ACL: the guard is a resource-lifecycle fix, and the second attach was never an /// authorization bypass — B had already been cleared by the newer attach. /// /// /// This case needs a renderer that can re-set parameters on the SAME component instance, which /// the static used by the tests above cannot do — it renders a root /// component once and exposes no parameter-update seam. Hence the minimal /// below, which is the smallest thing that can express /// a second SetParametersAsync while the first is still suspended. /// /// /// A task that represents the asynchronous operation. [Fact] public async Task Page_WhenNavigationOvertakesASuspendedAttach_LeavesOneSubscription() { RecordingEventSubscriber subscriber = new(); // Call 1 is OnInitializedAsync's CanManage lookup; call 2 is the first session's attach, // which is the one that must be caught mid-flight. GatedAuthenticationStateProvider auth = new(gateOnCall: 2); ServiceCollection services = BuildServices(subscriber, allow: true, authenticationStateProvider: auth); await using ServiceProvider provider = services.BuildServiceProvider(); await using ParameterDrivingRenderer renderer = new(provider, provider.GetRequiredService()); SessionDetailsPage page = await renderer.MountAsync(); // Not awaited: it parks inside the first attach, which is the whole point. Task first = renderer.SetParametersAsync(page, "session-a"); await auth.Entered.WaitAsync(TestTimeout); // The overtaking navigation completes end to end while the first attach is suspended. await renderer.SetParametersAsync(page, "session-b").WaitAsync(TestTimeout); auth.Release(); await first.WaitAsync(TestTimeout); Assert.Empty(renderer.Exceptions); // Without the generation guard this is ["session-b", "session-b"] and the first of the two // is stranded — the exact leak the guard exists to prevent. Assert.Equal(["session-b"], subscriber.SubscribedSessionIds); Assert.Empty(subscriber.UndisposedAfterReplacement); } private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(30); private static ServiceCollection BuildServices( RecordingEventSubscriber subscriber, bool allow, AuthenticationStateProvider? authenticationStateProvider = null) { ServiceCollection services = new(); services.AddLogging(); services.AddSingleton(new StubSnapshotService()); services.AddSingleton(new IdleSnapshotFeed()); services.AddSingleton(new NonManagingSessionAdminService()); services.AddSingleton(subscriber); services.AddSingleton(new StubSessionAcl(allow)); services.AddSingleton( authenticationStateProvider ?? new StubAuthenticationStateProvider()); return services; } private static async Task RenderAsync(RecordingEventSubscriber subscriber, bool allow) { await using ServiceProvider provider = BuildServices(subscriber, allow).BuildServiceProvider(); await using HtmlRenderer renderer = new(provider, provider.GetRequiredService()); return await renderer.Dispatcher.InvokeAsync(async () => { HtmlRootComponent output = await renderer.RenderComponentAsync( ParameterView.FromDictionary(new Dictionary { [nameof(SessionDetailsPage.SessionId)] = SessionId, })); return output.ToHtmlString(); }); } private sealed class StubSessionAcl(bool allow) : IDashboardSessionAcl { /// public bool CanViewSession(ClaimsPrincipal? principal, string sessionId) => allow; } private sealed class RecordingEventSubscriber : IDashboardSessionEventSubscriber { private readonly List _handedOut = []; /// Gets the session ids was called with, in order. public List SubscribedSessionIds { get; } = []; /// /// Gets the subscriptions that were superseded by a later one and never disposed — the /// signature of a stranded subscription, whose viewer registration is never released. The /// most recent subscription is excluded because the page legitimately still holds it. /// public IReadOnlyList UndisposedAfterReplacement => [.. _handedOut.SkipLast(1).Where(subscription => !subscription.IsDisposed)]; /// public IDashboardEventSubscription Subscribe(string sessionId) { SubscribedSessionIds.Add(sessionId); IdleSubscription subscription = new(); _handedOut.Add(subscription); return subscription; } // A subscription whose channel never yields and never completes, so the page's pump parks // exactly as it would against a quiet session. internal sealed class IdleSubscription : IDashboardEventSubscription { private readonly Channel _channel = Channel.CreateUnbounded(); /// Gets a value indicating whether the page released this subscription. public bool IsDisposed { get; private set; } /// public ChannelReader Reader => _channel.Reader; /// Marks the subscription as released and completes its channel. public void Dispose() { IsDisposed = true; _channel.Writer.TryComplete(); } } } // Gates one nominated call so a test can suspend an attach exactly where the ACL check made it // asynchronous, and let a second parameter set overtake it. private sealed class GatedAuthenticationStateProvider(int gateOnCall) : AuthenticationStateProvider { private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); private int _calls; /// Completes once the gated call has been entered and is parked. public Task Entered => _entered.Task; /// Lets the parked call finish. public void Release() => _release.TrySetResult(); /// public override async Task GetAuthenticationStateAsync() { if (Interlocked.Increment(ref _calls) == gateOnCall) { _entered.TrySetResult(); await _release.Task.ConfigureAwait(false); } return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity( [new Claim(ClaimTypes.Name, "viewer-user"), new Claim(ClaimTypes.Role, DashboardRoles.Viewer)], authenticationType: "test", nameType: ClaimTypes.Name, roleType: ClaimTypes.Role))); } } // The smallest renderer that can drive a SECOND parameter set into an already-mounted // component instance. HtmlRenderer renders a root component once and offers no such seam, so // the interleaving under test is inexpressible with it; everything here is plumbing around // Renderer's protected mount/parameter surface, with no behaviour of its own. // // BL0006 warns that Microsoft.AspNetCore.Components.RenderTree is not for use outside the // Blazor framework because those types may change between releases. Suppressed here and only // here: this is test-only scaffolding (the same thing component-testing packages do), it never // ships, and the cost of the warning coming true is a compile break in one test file on an SDK // bump — not a production defect. Production code must keep honouring BL0006. #pragma warning disable BL0006 private sealed class ParameterDrivingRenderer(IServiceProvider services, ILoggerFactory loggerFactory) : Renderer(services, loggerFactory) { /// Gets exceptions the renderer surfaced, so a test never passes over a swallowed fault. public List Exceptions { get; } = []; /// public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault(); /// Instantiates the component with DI-injected properties and attaches it as a root. /// Component type to mount. /// The mounted component instance. public Task MountAsync() where TComponent : IComponent { return Dispatcher.InvokeAsync(() => { TComponent component = (TComponent)InstantiateComponent(typeof(TComponent)); AssignRootComponentId(component); return component; }); } /// Sets the session-id parameter on an already-mounted page. /// The mounted page. /// Session id to render. /// The task the component's parameter-set lifecycle returns. public Task SetParametersAsync(IComponent component, string sessionId) { return Dispatcher.InvokeAsync(() => component.SetParametersAsync( ParameterView.FromDictionary(new Dictionary { [nameof(SessionDetailsPage.SessionId)] = sessionId, }))); } /// protected override void HandleException(Exception exception) => Exceptions.Add(exception); /// protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask; } #pragma warning restore BL0006 private sealed class StubSnapshotService : IDashboardSnapshotService { /// public DashboardSnapshot GetSnapshot() => new( GeneratedAt: DateTimeOffset.UnixEpoch, GatewayStartedAt: DateTimeOffset.UnixEpoch, GatewayUptime: TimeSpan.Zero, GatewayStatus: "Healthy", GatewayVersion: "test", Sessions: [ new DashboardSessionSummary( SessionId: SessionId, BackendName: "backend", State: SessionState.Ready, ClientIdentity: "client", ClientSessionName: "client-session", ClientCorrelationId: "correlation", OpenedAt: DateTimeOffset.UnixEpoch, LastClientActivityAt: DateTimeOffset.UnixEpoch, LeaseExpiresAt: null, WorkerProcessId: null, WorkerState: null, LastWorkerHeartbeatAt: null, EventsReceived: 0, LastFault: null), ], Workers: [], Metrics: [], Faults: [], ApiKeys: [], Configuration: null!, Galaxy: null!); /// public IAsyncEnumerable WatchSnapshotsAsync(CancellationToken cancellationToken) => new IdleSnapshotFeed().WatchAsync(cancellationToken); } // Parks until the page is disposed, so the base page's watch loop neither spins nor pushes a // second snapshot mid-assertion. private sealed class IdleSnapshotFeed : IDashboardSnapshotFeed { /// public async IAsyncEnumerable WatchAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); yield break; } } private sealed class NonManagingSessionAdminService : IDashboardSessionAdminService { /// public bool CanManage(ClaimsPrincipal user) => false; /// public Task CloseSessionAsync( ClaimsPrincipal user, string sessionId, CancellationToken cancellationToken) => Task.FromResult(DashboardSessionAdminResult.Fail("not supported")); /// public Task KillWorkerAsync( ClaimsPrincipal user, string sessionId, CancellationToken cancellationToken) => Task.FromResult(DashboardSessionAdminResult.Fail("not supported")); } private sealed class StubAuthenticationStateProvider : AuthenticationStateProvider { /// public override Task GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity( [new Claim(ClaimTypes.Name, "viewer-user"), new Claim(ClaimTypes.Role, DashboardRoles.Viewer)], authenticationType: "test", nameType: ClaimTypes.Name, roleType: ClaimTypes.Role)))); } }