Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverLegacyCredentialTests.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

147 lines
6.9 KiB
C#

using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// lmxopcua#815 warn-only compatibility (deprecated): an ALREADY-DEPLOYED Galaxy driver whose config predates the
/// namespaces — e.g. the old AdminUI form default <c>env:MX_API_KEY</c> — keeps resolving it on init and on every
/// gateway reconnect (<c>ReopenAsync</c> → <c>BuildClientOptionsAsync</c>), warning rate-limited and surfacing it
/// on the diagnostics bag; while the admin-node paths (browser) stay hard-fail.
/// </summary>
public sealed class GalaxyDriverLegacyCredentialTests
{
private const string LegacyVariable = "MX_API_KEY_815_TEST";
private static GalaxyDriverOptions Opts(string apiKeyRef, string? ackRef = null) => new(
new GalaxyGatewayOptions("https://mxgw.test:5001", apiKeyRef),
new GalaxyMxAccessOptions("Compat815"),
new GalaxyRepositoryOptions(WatchDeployEvents: false),
new GalaxyReconnectOptions())
{
AlarmAck = ackRef is null ? new GalaxyAlarmAckOptions() : new GalaxyAlarmAckOptions("ack-svc", null, ackRef),
};
[Fact]
public async Task A_running_driver_with_a_legacy_env_ref_resolves_on_init_and_every_reconnect_and_warns_once()
{
Environment.SetEnvironmentVariable(LegacyVariable, "the-real-key");
try
{
var logger = new CaptureLogger();
var driver = new GalaxyDriver("gx-legacy", Opts($"env:{LegacyVariable}"), new NullResolver(), logger);
var ct = TestContext.Current.CancellationToken;
// InitializeAsync and each ReopenAsync (gateway reconnect) build the client options through this one method.
(await driver.BuildClientOptionsAsync(Opts($"env:{LegacyVariable}").Gateway, ct)).ApiKey.ShouldBe("the-real-key");
(await driver.BuildClientOptionsAsync(Opts($"env:{LegacyVariable}").Gateway, ct)).ApiKey.ShouldBe("the-real-key");
(await driver.BuildClientOptionsAsync(Opts($"env:{LegacyVariable}").Gateway, ct)).ApiKey.ShouldBe("the-real-key");
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning && e.Message.Contains("lmxopcua#815")).ToList();
warnings.Count.ShouldBe(1); // rate-limited: one per field per hour, not one per reconnect
warnings[0].Message.ShouldContain("Gateway.ApiKeySecretRef");
warnings[0].Message.ShouldContain("env:OTOPCUA_GALAXY_<NAME>");
warnings[0].Message.ShouldNotContain("the-real-key");
var diagnostics = ((IDriverDiagnostics)driver).GetDiagnostics();
diagnostics.Keys.ShouldContain(LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "Gateway.ApiKeySecretRef");
diagnostics.Values.ShouldAllBe(v => !v.Contains("the-real-key"));
DriverDiagnostics.Bound(diagnostics).Values.ShouldAllBe(v => v.Length <= DriverDiagnostics.MaxValueLength);
}
finally
{
Environment.SetEnvironmentVariable(LegacyVariable, null);
}
}
[Fact]
public async Task A_running_driver_keeps_the_old_case_insensitive_and_file_semantics()
{
var path = Path.Combine(Path.GetTempPath(), $"gx-815-{Guid.NewGuid():N}.txt");
File.WriteAllText(path, " key-from-file \n");
try
{
var driver = new GalaxyDriver("gx-legacy-file", Opts($"file:{path}"), new NullResolver());
(await driver.BuildClientOptionsAsync(Opts($"file:{path}").Gateway, TestContext.Current.CancellationToken))
.ApiKey.ShouldBe("key-from-file");
((IDriverDiagnostics)driver).GetDiagnostics().ShouldNotBeEmpty();
}
finally
{
File.Delete(path);
}
}
[Fact]
public async Task A_namespaced_ref_resolves_without_a_compatibility_warning()
{
var logger = new CaptureLogger();
var driver = new GalaxyDriver("gx-ok", Opts("dev:k"), new NullResolver(), logger);
(await driver.BuildClientOptionsAsync(Opts("dev:k").Gateway, TestContext.Current.CancellationToken)).ApiKey.ShouldBe("k");
logger.Entries.ShouldNotContain(e => e.Message.Contains("lmxopcua#815"));
((IDriverDiagnostics)driver).GetDiagnostics().ShouldBeEmpty();
}
/// <summary>The same legacy ref through the admin-node browser path (no tracker) is refused before any read.</summary>
[Fact]
public async Task The_same_ref_without_a_tracker_is_refused()
{
var reads = 0;
await Should.ThrowAsync<CredentialReferenceException>(() => GalaxySecretRef.ResolveApiKeyAsync(
"env:MX_API_KEY", new NullResolver(), ct: TestContext.Current.CancellationToken,
environment: _ => { reads++; return "the-real-key"; }));
reads.ShouldBe(0);
}
[Fact]
public async Task The_running_ack_fallback_keeps_a_legacy_ref_and_warns()
{
var warnings = new List<string>();
var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.GalaxyScope, "gx", warnings.Add);
var resolver = new GalaxyAckCredentialResolver(
"gx", new GalaxyAlarmAckOptions("ack-svc", null, "env:LEGACY_ACK_PW"), new NullResolver(), new CaptureLogger(),
n => n == "LEGACY_ACK_PW" ? "pw" : null, tracker);
(await resolver.ResolveAsync(null, TestContext.Current.CancellationToken)).Credential.ShouldNotBeNull().Password.ShouldBe("pw");
warnings.ShouldHaveSingleItem().ShouldContain("AlarmAck.FallbackPasswordSecretRef");
}
[Fact]
public void The_tracker_re_warns_after_the_interval()
{
var now = new DateTime(2026, 11, 11, 0, 0, 0, DateTimeKind.Utc);
var warnings = new List<string>();
var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.GalaxyScope, "gx", warnings.Add, () => now);
tracker.Report("F");
tracker.Report("F");
now += LegacyCredentialReferenceTracker.WarnInterval;
tracker.Report("F");
tracker.Report("G");
warnings.Count.ShouldBe(3);
}
private sealed class NullResolver : ISecretResolver
{
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
}
private sealed class CaptureLogger : ILogger<GalaxyDriver>
{
public List<(LogLevel Level, string Message)> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
}
}
}