Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/GalaxyDriverBrowserTests.cs
T
Joseph Doherty 4563940792 fix(security): warn-only compatibility for deployed drivers + upgrade preflight (#815)
User decision: save, Test Connect, live browse and the deploy gate keep
refusing out-of-namespace credential references, but an ALREADY-DEPLOYED
driver must not fault at the upgrade restart or (Galaxy) at the next
gateway reconnect, since ReopenAsync re-resolves Gateway.ApiKeySecretRef.

- LegacyCredentialReferenceTracker (Core.Abstractions): the one, deprecated,
  call-site-chosen compatibility path. Running Galaxy (InitializeAsync,
  ReopenAsync, ack fallback) and OpcUaClient (session open) pass it and keep
  the pre-#815 semantics (case-insensitive prefixes, Galaxy file:, OpcUaClient
  literal pass-through) with a rate-limited Warning (driver/field/allowed
  form, never the value) and a credential-compat:<field> diagnostics entry
  (both drivers now implement IDriverDiagnostics). Browsers and probes pass
  none and stay hard-fail; Heidenhain has none.
- Deploy-gate message names the migration.
- Upgrade preflight: CredentialReferencePreflightService (admin nodes, once
  after ApplicationStarted, ConfigDb only, resolves nothing) logs a Warning
  per Draft row / Deployed-artifact finding; the /raw banner shows them plus
  Running drivers reporting warn-only use (driver-health diagnostics).
- docs/security.md: exact replacement table (incl. the old form default
  env:MX_API_KEY, file:, un-namespaced secret:, casing changes), the
  ReopenAsync reconnect behaviour, the warn-only window and its removal.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 02:07:33 -05:00

84 lines
4.3 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
/// <summary>
/// Unit-only coverage of <see cref="GalaxyDriverBrowser"/>'s pre-connect validation.
/// These tests do not require a live mxaccessgw endpoint and are safe to run without
/// the gateway fixture — they exercise the JSON deserialization + validation paths
/// that run before <c>GalaxyRepositoryClient.Create</c> + <c>TestConnectionAsync</c>.
/// The factory's transport-construction path is covered by the integration suite
/// (Task 17) and manual smoke (Task 18) since both require a real gateway.
/// </summary>
[Trait("Category", "Unit")]
public sealed class GalaxyDriverBrowserTests
{
// These unit tests exercise only the pre-connect validation paths (empty endpoint,
// blank client name) that throw BEFORE the API-key secret: arm is resolved, so a
// null-returning stub resolver suffices — it is never consulted.
private readonly GalaxyDriverBrowser _sut = new(new StubSecretResolver());
/// <summary>A no-op resolver: every name resolves to null. Never consulted by these tests.</summary>
private sealed class StubSecretResolver : ISecretResolver
{
/// <inheritdoc />
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
}
/// <summary>The DriverType key must match the AdminUI's persisted "GalaxyMxGateway" value
/// so the factory wire-up picks the right browser implementation.</summary>
[Fact]
public void DriverType_is_GalaxyMxGateway() => _sut.DriverType.ShouldBe("GalaxyMxGateway");
/// <summary>An empty Gateway.Endpoint must fail fast with a clear, endpoint-mentioning
/// message rather than surfacing a downstream gRPC URI parse error.</summary>
[Fact]
public async Task OpenAsync_with_empty_endpoint_throws_InvalidOperationException()
{
var json = """{"Gateway":{"Endpoint":"","ApiKeySecretRef":"dev:k"},"MxAccess":{"ClientName":"X"},"Repository":{},"Reconnect":{}}""";
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => _sut.OpenAsync(json, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Endpoint");
}
/// <summary>An empty MxAccess.ClientName must fail fast — refused so the gateway
/// side doesn't see anonymous browse sessions during triage.</summary>
[Fact]
public async Task OpenAsync_with_empty_clientName_throws_InvalidOperationException()
{
var json = """{"Gateway":{"Endpoint":"http://127.0.0.1:1","ApiKeySecretRef":"dev:k"},"MxAccess":{"ClientName":""},"Repository":{},"Reconnect":{}}""";
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => _sut.OpenAsync(json, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("ClientName");
}
/// <summary>
/// lmxopcua#815: the browser runs on the admin node against an operator-chosen endpoint with no deploy, so it
/// never gets the running driver's warn-only compatibility — a legacy ref (the old form default
/// <c>env:MX_API_KEY</c>, a <c>file:</c> path, another subsystem's secret) is refused before any connect.
/// </summary>
[Theory]
[InlineData("env:MX_API_KEY")]
[InlineData("file:appsettings.json")]
[InlineData("secret:otopcua/historian/api-key")]
public async Task OpenAsync_refuses_an_out_of_namespace_api_key_ref_with_no_warn_only_fallback(string reference)
{
var json = "{\"Gateway\":{\"Endpoint\":\"http://127.0.0.1:1\",\"ApiKeySecretRef\":\"" + reference
+ "\"},\"MxAccess\":{\"ClientName\":\"X\"},\"Repository\":{},\"Reconnect\":{}}";
await Should.ThrowAsync<ZB.MOM.WW.OtOpcUa.Core.Abstractions.CredentialReferenceException>(
() => _sut.OpenAsync(json, TestContext.Current.CancellationToken));
}
/// <summary>A JSON literal that deserializes to null must fail fast with a
/// "deserialized to null" message — never a downstream NRE.</summary>
[Fact]
public async Task OpenAsync_with_null_json_throws_InvalidOperationException()
{
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => _sut.OpenAsync("null", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("null");
}
}