f40409a688
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
397 lines
18 KiB
C#
397 lines
18 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Covers the ACL gate on the session-details page's in-process subscribe seam.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The 2026-08 in-process feed refactor gave the dashboard a second way to subscribe to a
|
|
/// session's events — <see cref="IDashboardSessionEventSubscriber"/>, 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
|
|
/// <see cref="IDashboardSessionEventSubscriber.Subscribe"/> is never called, not merely that the
|
|
/// panel renders differently.
|
|
/// </para>
|
|
/// <para>
|
|
/// Rendered through the framework's static <see cref="HtmlRenderer"/>, the same idiom
|
|
/// <c>SecretsNavRenderTests</c> uses — no component-testing package, because the assertions are
|
|
/// about the emitted markup and the calls the lifecycle makes, not about interactivity.
|
|
/// </para>
|
|
/// </remarks>
|
|
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.";
|
|
|
|
/// <summary>A denied caller gets the message and no subscription is opened.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The re-entrancy guard on <c>AttachEventsAsync</c>: a rapid A -> B navigation must leave
|
|
/// exactly one live subscription, not two.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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
|
|
/// <c>SessionId</c> 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
|
|
/// <c>EventsHubViewerRegistry</c> entry never released (so the mirror keeps cloning events
|
|
/// for it), its pump never cancelled.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// This case needs a renderer that can re-set parameters on the SAME component instance, which
|
|
/// the static <see cref="HtmlRenderer"/> used by the tests above cannot do — it renders a root
|
|
/// component once and exposes no parameter-update seam. Hence the minimal
|
|
/// <see cref="ParameterDrivingRenderer"/> below, which is the smallest thing that can express
|
|
/// a second <c>SetParametersAsync</c> while the first is still suspended.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<ILoggerFactory>());
|
|
|
|
SessionDetailsPage page = await renderer.MountAsync<SessionDetailsPage>();
|
|
|
|
// 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<IDashboardSnapshotService>(new StubSnapshotService());
|
|
services.AddSingleton<IDashboardSnapshotFeed>(new IdleSnapshotFeed());
|
|
services.AddSingleton<IDashboardSessionAdminService>(new NonManagingSessionAdminService());
|
|
services.AddSingleton<IDashboardSessionEventSubscriber>(subscriber);
|
|
services.AddSingleton<IDashboardSessionAcl>(new StubSessionAcl(allow));
|
|
services.AddSingleton<AuthenticationStateProvider>(
|
|
authenticationStateProvider ?? new StubAuthenticationStateProvider());
|
|
|
|
return services;
|
|
}
|
|
|
|
private static async Task<string> RenderAsync(RecordingEventSubscriber subscriber, bool allow)
|
|
{
|
|
await using ServiceProvider provider = BuildServices(subscriber, allow).BuildServiceProvider();
|
|
await using HtmlRenderer renderer = new(provider, provider.GetRequiredService<ILoggerFactory>());
|
|
|
|
return await renderer.Dispatcher.InvokeAsync(async () =>
|
|
{
|
|
HtmlRootComponent output = await renderer.RenderComponentAsync<SessionDetailsPage>(
|
|
ParameterView.FromDictionary(new Dictionary<string, object?>
|
|
{
|
|
[nameof(SessionDetailsPage.SessionId)] = SessionId,
|
|
}));
|
|
|
|
return output.ToHtmlString();
|
|
});
|
|
}
|
|
|
|
private sealed class StubSessionAcl(bool allow) : IDashboardSessionAcl
|
|
{
|
|
/// <inheritdoc />
|
|
public bool CanViewSession(ClaimsPrincipal? principal, string sessionId) => allow;
|
|
}
|
|
|
|
private sealed class RecordingEventSubscriber : IDashboardSessionEventSubscriber
|
|
{
|
|
private readonly List<IdleSubscription> _handedOut = [];
|
|
|
|
/// <summary>Gets the session ids <see cref="Subscribe"/> was called with, in order.</summary>
|
|
public List<string> SubscribedSessionIds { get; } = [];
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public IReadOnlyList<IdleSubscription> UndisposedAfterReplacement =>
|
|
[.. _handedOut.SkipLast(1).Where(subscription => !subscription.IsDisposed)];
|
|
|
|
/// <inheritdoc />
|
|
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<MxEvent> _channel = Channel.CreateUnbounded<MxEvent>();
|
|
|
|
/// <summary>Gets a value indicating whether the page released this subscription.</summary>
|
|
public bool IsDisposed { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public ChannelReader<MxEvent> Reader => _channel.Reader;
|
|
|
|
/// <summary>Marks the subscription as released and completes its channel.</summary>
|
|
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;
|
|
|
|
/// <summary>Completes once the gated call has been entered and is parked.</summary>
|
|
public Task Entered => _entered.Task;
|
|
|
|
/// <summary>Lets the parked call finish.</summary>
|
|
public void Release() => _release.TrySetResult();
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<AuthenticationState> 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)
|
|
{
|
|
/// <summary>Gets exceptions the renderer surfaced, so a test never passes over a swallowed fault.</summary>
|
|
public List<Exception> Exceptions { get; } = [];
|
|
|
|
/// <inheritdoc />
|
|
public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault();
|
|
|
|
/// <summary>Instantiates the component with DI-injected properties and attaches it as a root.</summary>
|
|
/// <typeparam name="TComponent">Component type to mount.</typeparam>
|
|
/// <returns>The mounted component instance.</returns>
|
|
public Task<TComponent> MountAsync<TComponent>()
|
|
where TComponent : IComponent
|
|
{
|
|
return Dispatcher.InvokeAsync(() =>
|
|
{
|
|
TComponent component = (TComponent)InstantiateComponent(typeof(TComponent));
|
|
AssignRootComponentId(component);
|
|
|
|
return component;
|
|
});
|
|
}
|
|
|
|
/// <summary>Sets the session-id parameter on an already-mounted page.</summary>
|
|
/// <param name="component">The mounted page.</param>
|
|
/// <param name="sessionId">Session id to render.</param>
|
|
/// <returns>The task the component's parameter-set lifecycle returns.</returns>
|
|
public Task SetParametersAsync(IComponent component, string sessionId)
|
|
{
|
|
return Dispatcher.InvokeAsync(() => component.SetParametersAsync(
|
|
ParameterView.FromDictionary(new Dictionary<string, object?>
|
|
{
|
|
[nameof(SessionDetailsPage.SessionId)] = sessionId,
|
|
})));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override void HandleException(Exception exception) => Exceptions.Add(exception);
|
|
|
|
/// <inheritdoc />
|
|
protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask;
|
|
}
|
|
#pragma warning restore BL0006
|
|
|
|
private sealed class StubSnapshotService : IDashboardSnapshotService
|
|
{
|
|
/// <inheritdoc />
|
|
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!);
|
|
|
|
/// <inheritdoc />
|
|
public IAsyncEnumerable<DashboardSnapshot> 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
|
|
{
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<DashboardSnapshot> WatchAsync(
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
|
|
|
|
yield break;
|
|
}
|
|
}
|
|
|
|
private sealed class NonManagingSessionAdminService : IDashboardSessionAdminService
|
|
{
|
|
/// <inheritdoc />
|
|
public bool CanManage(ClaimsPrincipal user) => false;
|
|
|
|
/// <inheritdoc />
|
|
public Task<DashboardSessionAdminResult> CloseSessionAsync(
|
|
ClaimsPrincipal user,
|
|
string sessionId,
|
|
CancellationToken cancellationToken) =>
|
|
Task.FromResult(DashboardSessionAdminResult.Fail("not supported"));
|
|
|
|
/// <inheritdoc />
|
|
public Task<DashboardSessionAdminResult> KillWorkerAsync(
|
|
ClaimsPrincipal user,
|
|
string sessionId,
|
|
CancellationToken cancellationToken) =>
|
|
Task.FromResult(DashboardSessionAdminResult.Fail("not supported"));
|
|
}
|
|
|
|
private sealed class StubAuthenticationStateProvider : AuthenticationStateProvider
|
|
{
|
|
/// <inheritdoc />
|
|
public override Task<AuthenticationState> 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))));
|
|
}
|
|
}
|