271b96a644
I-1: Test Connect / browse configs may carry unsaved edits (the device modal merges the stored DriverConfig with the typed device config). The server (DriverInstanceIdentities.EffectiveOwnerAsync, used by AdminOperationsActor and BrowserSessionService) honours the named owner only when every endpoint value (new DriverCredentialRules.EndpointFields: Galaxy Endpoint, OpcUaClient EndpointUrl/EndpointUrls/DiscoveryUrl, Heidenhain Host/Port) is one that driver stores; otherwise the owner is null and its instance-named secrets are refused. Every Test Connect / browse whose config carries an in-scope secret:/env: reference is recorded (CredentialUseAudit: IAuditWriter event category CredentialUse + a structured Information line: user, driver, endpoint hosts, outcome). L-1: the UserCertificatePath rule is skipped only in an object that explicitly sets AuthType to non-Certificate (string or ordinal); the deploy gate also checks the runtime-merged driver+device config so the switch cannot be split from the path; the form no longer persists the hidden field. L-2: a ConfigDb fault in the browse check is a refusal; RawBrowseModal catches open failures into its error message. L-4: a relative UserCertificateDirectory resolves against AppContext.BaseDirectory. Co-Authored-By: Claude <noreply@anthropic.com>
414 lines
17 KiB
C#
414 lines
17 KiB
C#
using System.Reflection;
|
|
using System.Runtime.ExceptionServices;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.AspNetCore.Components.RenderTree;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
|
|
|
|
/// <summary>
|
|
/// Pins that a superseded open of the browse modal can never write its device's browse context under
|
|
/// a different device's displayed modal.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Blazor does not serialize <c>OnParametersSetAsync</c> against its own outstanding task, and the
|
|
/// modal's <c>_open</c> bool cannot tell two opens apart. Its resolve awaits the merged probe
|
|
/// config and the target group context, so without a post-await staleness re-check a
|
|
/// close-reopen onto another device lets the first open's late continuation install its
|
|
/// <c>_driverType</c> / <c>_mergedConfig</c> / <c>_groupPrefix</c> — and "Connect & browse"
|
|
/// would then silently dial the wrong controller and commit under the wrong group. Same race, same
|
|
/// fix as the two sibling modals (<c>RawTagModal</c>, <c>RawManualTagEntryModal</c>, 2026-08-05).
|
|
/// </para>
|
|
/// <para>
|
|
/// AdminUI has no bUnit, so the component is driven directly as a class: its injected properties,
|
|
/// its <c>[Parameter]</c>s (BL0005 forbids setting those from outside the component, and this
|
|
/// project treats warnings as errors) and its private resolve state are reached by reflection.
|
|
/// Unlike the siblings this modal calls <c>StateHasChanged</c> on the open path, which throws
|
|
/// unless the component holds a render handle — hence the inert renderer below.
|
|
/// </para>
|
|
/// </remarks>
|
|
// BL0006 warns off Microsoft.AspNetCore.Components.RenderTree, which is right for production code and
|
|
// unavoidable here: subclassing Renderer is the only way to hand a component a render handle without
|
|
// taking a bUnit dependency, and Renderer's abstract render method is typed in that namespace. The surface
|
|
// used is that one parameter — no RenderTree type is inspected, stored or asserted on.
|
|
#pragma warning disable BL0006
|
|
public sealed class RawBrowseModalStaleOpenTests : IDisposable
|
|
{
|
|
private const string ConfigA = """{"endpoint":"10.0.0.1:44818","slot":0}""";
|
|
private const string ConfigB = """{"endpoint":"10.0.0.2:44818","slot":1}""";
|
|
|
|
private readonly List<InertRenderer> _renderers = new();
|
|
|
|
[Fact]
|
|
public async Task A_stale_open_never_feeds_the_browser_another_devices_config()
|
|
{
|
|
var deviceAResolve = new TaskCompletionSource();
|
|
var svc = new FakeRawTreeService
|
|
{
|
|
OnLoadMergedProbeConfigAsync = async deviceId =>
|
|
{
|
|
if (deviceId == "dev-A") { await deviceAResolve.Task; return ("AbCip", ConfigA); }
|
|
return ("TwinCAT", ConfigB);
|
|
},
|
|
};
|
|
|
|
var modal = NewModal(svc, new FakeBrowserSessionService(), NullExportReader());
|
|
|
|
// Quick close-reopen onto another device while dev-A's resolve is still in flight.
|
|
SetParameters(modal, visible: true, deviceId: "dev-A", tagGroupId: null);
|
|
var staleOpen = OpenAsync(modal);
|
|
|
|
SetParameters(modal, visible: false, deviceId: "dev-A", tagGroupId: null);
|
|
await OpenAsync(modal);
|
|
|
|
SetParameters(modal, visible: true, deviceId: "dev-B", tagGroupId: null);
|
|
await OpenAsync(modal);
|
|
|
|
deviceAResolve.SetResult();
|
|
await staleOpen;
|
|
|
|
Field<string>(modal, "_mergedConfig").ShouldBe(ConfigB);
|
|
Field<string>(modal, "_driverType").ShouldBe("TwinCAT");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_stale_group_resolve_never_retargets_the_displayed_open()
|
|
{
|
|
var dbName = SeedGroup("TG-A", deviceId: "dev-A", name: "GroupA");
|
|
var groupResolve = new TaskCompletionSource();
|
|
|
|
var svc = new FakeRawTreeService
|
|
{
|
|
OnLoadMergedProbeConfig = deviceId =>
|
|
deviceId == "dev-A" ? ("AbCip", ConfigA) : null,
|
|
};
|
|
|
|
var modal = NewModal(
|
|
svc,
|
|
new FakeBrowserSessionService(),
|
|
new RawTagCsvExportReader(new GatedDbContextFactory(dbName, groupResolve.Task)));
|
|
|
|
// The merged-config resolve completes inline here, so this open suspends inside the GROUP
|
|
// resolve — the second await, which a fix that guards only the first one leaves unprotected.
|
|
SetParameters(modal, visible: true, deviceId: "dev-A", tagGroupId: "TG-A");
|
|
var staleOpen = OpenAsync(modal);
|
|
|
|
SetParameters(modal, visible: false, deviceId: "dev-A", tagGroupId: "TG-A");
|
|
await OpenAsync(modal);
|
|
|
|
// Re-opened with no device and no target group: the displayed open commits at a device root it
|
|
// has not resolved yet, so both writes the stale continuation makes are observable.
|
|
SetParameters(modal, visible: true, deviceId: "", tagGroupId: null);
|
|
await OpenAsync(modal);
|
|
|
|
groupResolve.SetResult();
|
|
await staleOpen;
|
|
|
|
Field<string?>(modal, "_groupPrefix").ShouldBeNull();
|
|
Field<string>(modal, "_effectiveDeviceId").ShouldBe("");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_stale_open_completing_late_never_clears_the_live_opens_loading_state()
|
|
{
|
|
var deviceAResolve = new TaskCompletionSource();
|
|
var deviceBResolve = new TaskCompletionSource();
|
|
var svc = new FakeRawTreeService
|
|
{
|
|
OnLoadMergedProbeConfigAsync = async deviceId =>
|
|
{
|
|
if (deviceId == "dev-A") { await deviceAResolve.Task; return ("AbCip", ConfigA); }
|
|
await deviceBResolve.Task;
|
|
return ("TwinCAT", ConfigB);
|
|
},
|
|
};
|
|
|
|
var modal = NewModal(svc, new FakeBrowserSessionService(), NullExportReader());
|
|
|
|
SetParameters(modal, visible: true, deviceId: "dev-A", tagGroupId: null);
|
|
var staleOpen = OpenAsync(modal);
|
|
|
|
SetParameters(modal, visible: false, deviceId: "dev-A", tagGroupId: null);
|
|
await OpenAsync(modal);
|
|
|
|
SetParameters(modal, visible: true, deviceId: "dev-B", tagGroupId: null);
|
|
var liveOpen = OpenAsync(modal);
|
|
|
|
// The stale run finishes while the live one is still resolving. Its tail state — the spinner
|
|
// flag and the render it forces — belongs to an open that is no longer displayed: letting the
|
|
// `finally` run would drop the live open's spinner and paint the not-yet-resolved
|
|
// "Browsing unavailable" panel over a device that is still loading.
|
|
deviceAResolve.SetResult();
|
|
await staleOpen;
|
|
|
|
Field<bool>(modal, "_resolving").ShouldBeTrue();
|
|
|
|
deviceBResolve.SetResult();
|
|
await liveOpen;
|
|
|
|
Field<bool>(modal, "_resolving").ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_stale_resolve_failure_never_paints_its_error_over_the_live_open()
|
|
{
|
|
var deviceAResolve = new TaskCompletionSource();
|
|
var svc = new FakeRawTreeService
|
|
{
|
|
OnLoadMergedProbeConfigAsync = async deviceId =>
|
|
{
|
|
if (deviceId == "dev-A") { await deviceAResolve.Task; return ("AbCip", ConfigA); }
|
|
return ("TwinCAT", ConfigB);
|
|
},
|
|
};
|
|
|
|
var modal = NewModal(svc, new FakeBrowserSessionService(), NullExportReader());
|
|
|
|
SetParameters(modal, visible: true, deviceId: "dev-A", tagGroupId: null);
|
|
var staleOpen = OpenAsync(modal);
|
|
|
|
SetParameters(modal, visible: false, deviceId: "dev-A", tagGroupId: null);
|
|
await OpenAsync(modal);
|
|
|
|
SetParameters(modal, visible: true, deviceId: "dev-B", tagGroupId: null);
|
|
await OpenAsync(modal);
|
|
|
|
// A superseded open that FAILS is the same defect wearing the catch block: its diagnostic names
|
|
// a device the operator is no longer looking at, and disables browsing for the one they are.
|
|
deviceAResolve.SetException(new InvalidOperationException("dev-A unreachable"));
|
|
await staleOpen;
|
|
|
|
Field<bool>(modal, "_canBrowse").ShouldBeTrue();
|
|
Field<string?>(modal, "_disabledReason").ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// #835 review L-2: a fault inside "Connect & browse" (the browse service throwing — e.g. its ConfigDb owner check)
|
|
/// is shown on the modal, never escapes as a circuit error.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_faulting_browse_open_becomes_a_message_not_a_circuit_error()
|
|
{
|
|
var svc = new FakeRawTreeService { OnLoadMergedProbeConfig = _ => ("AbCip", ConfigA) };
|
|
var modal = NewModal(svc, new FakeBrowserSessionService(), NullExportReader()); // its OpenAsync throws
|
|
SetParameters(modal, visible: true, deviceId: "dev-A", tagGroupId: null);
|
|
await OpenAsync(modal);
|
|
|
|
var open = typeof(RawBrowseModal).GetMethod("OpenBrowseAsync", BindingFlags.NonPublic | BindingFlags.Instance);
|
|
open.ShouldNotBeNull("the browse-open handler was renamed — retarget this test.");
|
|
await Should.NotThrowAsync(() => (Task)open!.Invoke(modal, null)!);
|
|
|
|
Field<string?>(modal, "_openError").ShouldNotBeNull().ShouldContain("Browse could not be opened");
|
|
Field<bool>(modal, "_opening").ShouldBeFalse();
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void Dispose()
|
|
{
|
|
foreach (var renderer in _renderers) { renderer.Dispose(); }
|
|
}
|
|
|
|
/// <summary>Builds a modal with its three injected dependencies set and a render handle attached.</summary>
|
|
private RawBrowseModal NewModal(
|
|
IRawTreeService svc, IBrowserSessionService browser, RawTagCsvExportReader exportReader)
|
|
{
|
|
var modal = new RawBrowseModal();
|
|
Inject(modal, "Svc", svc);
|
|
Inject(modal, "BrowserService", browser);
|
|
Inject(modal, "ExportReader", exportReader);
|
|
|
|
var renderer = new InertRenderer();
|
|
_renderers.Add(renderer);
|
|
renderer.Attach(modal);
|
|
|
|
return modal;
|
|
}
|
|
|
|
/// <summary>An export reader over a factory that fails loudly — for tests that must never reach it.</summary>
|
|
private static RawTagCsvExportReader NullExportReader()
|
|
=> new(new GatedDbContextFactory(dbName: null, gate: Task.CompletedTask));
|
|
|
|
/// <summary>Seeds a lone tag-group row into a fresh InMemory database and returns its name.</summary>
|
|
private static string SeedGroup(string tagGroupId, string deviceId, string name)
|
|
{
|
|
var dbName = $"rawbrowse-{Guid.NewGuid():N}";
|
|
using var db = RawTreeTestDb.CreateNamed(dbName);
|
|
db.TagGroups.Add(new TagGroup { TagGroupId = tagGroupId, DeviceId = deviceId, Name = name });
|
|
db.SaveChanges();
|
|
return dbName;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the modal's <c>[Parameter]</c>s. By reflection because BL0005 forbids assigning component
|
|
/// parameters from outside the component, and this project treats warnings as errors.
|
|
/// </summary>
|
|
private static void SetParameters(
|
|
RawBrowseModal modal, bool visible, string deviceId, string? tagGroupId)
|
|
{
|
|
Set("Visible", visible);
|
|
Set("DeviceId", deviceId);
|
|
Set("TagGroupId", tagGroupId);
|
|
|
|
void Set(string name, object? value)
|
|
{
|
|
var property = typeof(RawBrowseModal).GetProperty(name);
|
|
property.ShouldNotBeNull($"the [Parameter] {name} was renamed or removed.");
|
|
property!.SetValue(modal, value);
|
|
}
|
|
}
|
|
|
|
/// <summary>Sets one of the <c>@inject</c>-generated private properties.</summary>
|
|
private static void Inject(RawBrowseModal modal, string name, object value)
|
|
{
|
|
var property = typeof(RawBrowseModal).GetProperty(
|
|
name,
|
|
BindingFlags.NonPublic | BindingFlags.Instance);
|
|
|
|
property.ShouldNotBeNull($"the injected {name} property was renamed or removed.");
|
|
|
|
property!.SetValue(modal, value);
|
|
}
|
|
|
|
/// <summary>Drives the open path — the lifecycle method the resolve hangs off.</summary>
|
|
private static async Task OpenAsync(RawBrowseModal modal)
|
|
{
|
|
var method = typeof(RawBrowseModal).GetMethod(
|
|
"OnParametersSetAsync",
|
|
BindingFlags.NonPublic | BindingFlags.Instance);
|
|
|
|
method.ShouldNotBeNull("the open path moved off OnParametersSetAsync — retarget this driver.");
|
|
|
|
await (Task)method!.Invoke(modal, null)!;
|
|
}
|
|
|
|
/// <summary>Reads one of the modal's private resolve-state fields.</summary>
|
|
private static T Field<T>(RawBrowseModal modal, string name)
|
|
{
|
|
var field = typeof(RawBrowseModal).GetField(name, BindingFlags.NonPublic | BindingFlags.Instance);
|
|
field.ShouldNotBeNull($"the modal's {name} field was renamed or removed.");
|
|
return (T)field!.GetValue(modal)!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// An <see cref="IDbContextFactory{TContext}"/> whose context creation can be held open, so a
|
|
/// test can suspend a resolve inside <c>ResolveGroupContextAsync</c> and interleave a second open.
|
|
/// A null database name means "must never be called".
|
|
/// </summary>
|
|
private sealed class GatedDbContextFactory(string? dbName, Task gate)
|
|
: IDbContextFactory<OtOpcUaConfigDbContext>
|
|
{
|
|
/// <inheritdoc/>
|
|
public OtOpcUaConfigDbContext CreateDbContext()
|
|
{
|
|
dbName.ShouldNotBeNull("the group-context resolve was reached by a test that forbids it.");
|
|
return RawTreeTestDb.CreateNamed(dbName!);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken ct = default)
|
|
{
|
|
await gate;
|
|
return CreateDbContext();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Browse gate stand-in: every driver+config can browse, so a test asserting that a stale run did
|
|
/// not disable browsing has a true baseline to be falsified against. Nothing else is reachable —
|
|
/// these tests never open a session.
|
|
/// </summary>
|
|
private sealed class FakeBrowserSessionService : IBrowserSessionService
|
|
{
|
|
/// <inheritdoc/>
|
|
public bool CanBrowse(string driverType, string configJson) => true;
|
|
|
|
/// <inheritdoc/>
|
|
public Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct)
|
|
=> throw new NotImplementedException();
|
|
|
|
/// <inheritdoc/>
|
|
public Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct)
|
|
=> throw new NotImplementedException();
|
|
|
|
/// <inheritdoc/>
|
|
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct)
|
|
=> throw new NotImplementedException();
|
|
|
|
/// <inheritdoc/>
|
|
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct)
|
|
=> throw new NotImplementedException();
|
|
|
|
/// <inheritdoc/>
|
|
public Task<int> RequestRebirthAsync(Guid token, string scope, CancellationToken ct)
|
|
=> throw new NotImplementedException();
|
|
|
|
/// <inheritdoc/>
|
|
public bool CanRequestRebirth(Guid token) => false;
|
|
|
|
/// <inheritdoc/>
|
|
public Task CloseAsync(Guid token) => throw new NotImplementedException();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The smallest renderer that gives a component a working render handle: <c>StateHasChanged</c>
|
|
/// throws without one, and this modal calls it on the open path. Renders are built and then
|
|
/// discarded — the tests read the modal's state, not its markup.
|
|
/// </summary>
|
|
private sealed class InertRenderer()
|
|
: Renderer(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance)
|
|
{
|
|
/// <inheritdoc/>
|
|
public override Dispatcher Dispatcher { get; } = new InlineDispatcher();
|
|
|
|
/// <summary>Assigns the component a render handle, as a root component would receive.</summary>
|
|
public void Attach(IComponent component) => AssignRootComponentId(component);
|
|
|
|
/// <inheritdoc/>
|
|
protected override void HandleException(Exception exception)
|
|
=> ExceptionDispatchInfo.Capture(exception).Throw();
|
|
|
|
/// <inheritdoc/>
|
|
protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs everything on the calling thread. The real dispatcher would refuse a
|
|
/// <c>StateHasChanged</c> raised from a test thread (or from a resolve continuation), which is
|
|
/// exactly what these tests do.
|
|
/// </summary>
|
|
private sealed class InlineDispatcher : Dispatcher
|
|
{
|
|
/// <inheritdoc/>
|
|
public override bool CheckAccess() => true;
|
|
|
|
/// <inheritdoc/>
|
|
public override Task InvokeAsync(Action workItem)
|
|
{
|
|
workItem();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override Task InvokeAsync(Func<Task> workItem) => workItem();
|
|
|
|
/// <inheritdoc/>
|
|
public override Task<TResult> InvokeAsync<TResult>(Func<TResult> workItem)
|
|
=> Task.FromResult(workItem());
|
|
|
|
/// <inheritdoc/>
|
|
public override Task<TResult> InvokeAsync<TResult>(Func<Task<TResult>> workItem) => workItem();
|
|
}
|
|
}
|