960d76ffcb
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.
169 lines
7.7 KiB
C#
169 lines
7.7 KiB
C#
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 2–6 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());
|
||
});
|
||
}
|
||
}
|