Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyBrowseSessionTests.cs
T
Joseph Doherty 960d76ffcb review(Driver.Galaxy.Browser): fix mis-shifted MapSecurityClass codes (High)
Review at HEAD 7286d320. Driver.Galaxy.Browser-001 (High): MapSecurityClass codes 2-6 were
all shifted vs the runtime SecurityClassification enum (wrong security labels in the picker)
-> corrected all 7 arms + tests. -002: DisposeAsync swallows concurrent ObjectDisposedException.
-003 (ResolveApiKey dup) deferred to Contracts.
2026-06-19 10:52:23 -04:00

169 lines
7.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ZB.MOM.WW.MxGateway.Client;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
/// <summary>
/// Pure-construction + lifecycle coverage of <see cref="GalaxyBrowseSession"/>.
/// The session is <c>internal</c>; visibility comes via <c>InternalsVisibleTo</c>
/// on the production project.
/// <para>
/// <b>Blocker:</b> the hierarchy/expand/attribute paths all call into
/// <see cref="GalaxyRepositoryClient"/>, which only ships an <c>internal</c>
/// transport seam (<c>IGalaxyRepositoryClientTransport</c>) and an <c>internal</c>
/// constructor — both keyed via <c>InternalsVisibleTo</c> on the vendored
/// <c>ZB.MOM.WW.MxGateway.Client</c> assembly, and only granted to that repo's own
/// <c>ZB.MOM.WW.MxGateway.Client.Tests</c>. We can't substitute a fake transport from
/// here without changing the upstream repo, and the public <c>Create</c>
/// factory always opens a real gRPC channel. So in-memory traversal coverage
/// (RootAsync / ExpandAsync / AttributesAsync, including the SecurityClass
/// mapping) is deferred to the integration suite (Task 17) and the manual
/// smoke pass (Task 18) — both of which run the gateway for real.
/// </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class GalaxyBrowseSessionTests
{
/// <summary>Builds a <see cref="GalaxyRepositoryClient"/> bound to an
/// unreachable endpoint. No connection is opened — <c>Create</c> just builds the
/// gRPC channel object — so this is safe to call without a fixture.</summary>
private static GalaxyRepositoryClient NewClient() =>
GalaxyRepositoryClient.Create(new MxGatewayClientOptions
{
Endpoint = new Uri("http://127.0.0.1:1"),
ApiKey = "test-key",
UseTls = false,
ConnectTimeout = TimeSpan.FromSeconds(1),
DefaultCallTimeout = TimeSpan.FromSeconds(1),
});
/// <summary>The internal ctor must reject a null client — the production caller
/// (the factory in <c>GalaxyDriverBrowser.OpenAsync</c>) hands off ownership of a
/// real client and never passes null, but defence-in-depth catches a future caller
/// who skips that handoff.</summary>
[Fact]
public void Constructor_with_null_client_throws_ArgumentNullException()
{
Should.Throw<ArgumentNullException>(() => new GalaxyBrowseSession(null!));
}
/// <summary>Each session must publish a distinct <see cref="GalaxyBrowseSession.Token"/>
/// so the AdminUI registry can disambiguate concurrent browse sessions against the
/// same driver config.</summary>
[Fact]
public async Task Token_is_unique_per_session()
{
await using var a = new GalaxyBrowseSession(NewClient());
await using var b = new GalaxyBrowseSession(NewClient());
a.Token.ShouldNotBe(b.Token);
a.Token.ShouldNotBe(Guid.Empty);
}
/// <summary><see cref="GalaxyBrowseSession.LastUsedUtc"/> is primed to the
/// construction time so the registry reaper has a sensible baseline before the
/// first Root/Expand/Attributes call lands.</summary>
[Fact]
public async Task LastUsedUtc_is_initialized_at_construction()
{
var before = DateTime.UtcNow;
await using var session = new GalaxyBrowseSession(NewClient());
var after = DateTime.UtcNow;
// Allow generous slop — the field is set inside the ctor body, both bookends
// are wall-clock UtcNow, and we only care that it isn't default(DateTime).
session.LastUsedUtc.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(-1));
session.LastUsedUtc.ShouldBeLessThanOrEqualTo(after.AddSeconds(1));
}
/// <summary><see cref="GalaxyBrowseSession.DisposeAsync"/> is idempotent — the
/// registry's reaper may race a client-initiated close, so the second call must
/// no-op rather than throw <see cref="ObjectDisposedException"/> or hit the
/// already-disposed gRPC channel.</summary>
[Fact]
public async Task DisposeAsync_is_idempotent()
{
var session = new GalaxyBrowseSession(NewClient());
await session.DisposeAsync();
// Second call should silently no-op.
await Should.NotThrowAsync(async () => await session.DisposeAsync());
}
/// <summary>After disposal, any <see cref="GalaxyBrowseSession.ExpandAsync"/> call
/// must surface <see cref="ObjectDisposedException"/> — not a downstream channel
/// fault — so the AdminUI sees a clean "session closed" signal.</summary>
[Fact]
public async Task ExpandAsync_after_dispose_throws_ObjectDisposedException()
{
var session = new GalaxyBrowseSession(NewClient());
await session.DisposeAsync();
await Should.ThrowAsync<ObjectDisposedException>(
() => session.ExpandAsync("anything", TestContext.Current.CancellationToken));
}
/// <summary><see cref="GalaxyBrowseSession.ExpandAsync"/> must reject a tag that
/// hasn't been seen by a prior Root/Expand call — the cache is the source of
/// truth, and silently returning [] would mask AdminUI bugs that browse with a
/// stale path.</summary>
[Fact]
public async Task ExpandAsync_unknown_tag_throws_ArgumentException()
{
await using var session = new GalaxyBrowseSession(NewClient());
// No RootAsync call ⇒ cache is empty ⇒ any tag is unknown.
await Should.ThrowAsync<ArgumentException>(
() => session.ExpandAsync("Galaxy.Unknown", TestContext.Current.CancellationToken));
}
/// <summary>
/// Verifies that each of the seven defined Galaxy security_classification codes
/// maps to the correct label string — matching the <c>SecurityClassification</c>
/// enum ordinals and the runtime <c>SecurityMap</c> in Driver.Galaxy.
/// (Regression for Driver.Galaxy.Browser-001: codes 26 were all wrong before
/// this fix.)
/// </summary>
[Theory]
[InlineData(0, "FreeAccess")]
[InlineData(1, "Operate")]
[InlineData(2, "SecuredWrite")]
[InlineData(3, "VerifiedWrite")]
[InlineData(4, "Tune")]
[InlineData(5, "Configure")]
[InlineData(6, "ViewOnly")]
public void MapSecurityClass_maps_all_known_codes(int code, string expectedLabel)
{
GalaxyBrowseSession.MapSecurityClass(code).ShouldBe(expectedLabel);
}
/// <summary>
/// An unrecognised Galaxy security_classification code must produce an
/// <c>Unknown(N)</c> label — not throw and not silently return a valid class.
/// </summary>
[Theory]
[InlineData(7)]
[InlineData(99)]
[InlineData(-1)]
public void MapSecurityClass_unknown_code_returns_Unknown_label(int code)
{
var label = GalaxyBrowseSession.MapSecurityClass(code);
label.ShouldStartWith("Unknown(");
label.ShouldContain(code.ToString());
}
/// <summary>
/// Two concurrent <see cref="GalaxyBrowseSession.DisposeAsync"/> calls on the
/// same session must not throw. The registry reaper and a browser-side Close
/// can race in production.
/// (Regression for Driver.Galaxy.Browser-002: the second caller could throw
/// <see cref="ObjectDisposedException"/> from <c>SemaphoreSlim.Dispose()</c>.)
/// </summary>
[Fact]
public async Task DisposeAsync_concurrent_calls_do_not_throw()
{
var session = new GalaxyBrowseSession(NewClient());
// Fire two concurrent dispose calls and await both — neither must throw.
await Should.NotThrowAsync(async () =>
{
await Task.WhenAll(session.DisposeAsync().AsTask(), session.DisposeAsync().AsTask());
});
}
}