Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Deployments/CredentialReferencePreflightTests.cs
T
Joseph Doherty f0c6cf9640
v2-ci / unit-tests (push) Has been cancelled
fix(security): running drivers refuse out-of-namespace credential refs at resolve time (#834)
The #815 warn-only compatibility window is over. A running Galaxy driver
(InitializeAsync, every gateway ReopenAsync, the ack fallback password) and a
running OpcUaClient driver (session open) now refuse an out-of-namespace
credential reference with the same CredentialReferenceException that save,
deploy (CredentialReferenceOutOfScope), Test Connect and browse already raise,
before any store / environment / file read and before any endpoint is dialled.
The one existing scope check (CredentialReferences.Parse/ResolveAsync) is now
the only path; nothing new was added.

Removed:
- LegacyCredentialReferenceTracker (Core.Abstractions), its rate-limited WARN
  and the credential-compat:<field> diagnostics entry.
- The per-driver Legacy* resolve methods and every `legacy` parameter
  (GalaxySecretRef, GalaxyAckCredentialResolver, OpcUaUserIdentityFactory,
  OpcUaUserCertificatePaths, OpcUaClientSecretResolution).
- IDriverDiagnostics on GalaxyDriver / OpcUaClientDriver (it only carried the
  tracker's entries).
- The tracker also kept a pre-#836 absolute UserCertificatePath loading on a
  running OpcUaClient driver; that is refused now too.

Preflight / banner decision: KEPT, as a hard error. The Running source
(driver-health credential-compat entries) and CredentialReferenceBannerModel
are gone with the tracker, but the ConfigDb scan still tells an operator
something no other surface does: a Deployed finding is now a driver that is
not connecting, a Draft finding is a deploy the gate will refuse - named
before anyone deploys, e.g. after restoring an old ConfigDb backup or
importing a pre-#815 config. It also carries the #835 cross-instance and
#836/#850 path checks. CredentialReferencePreflightService now logs one Error
per finding (was Warning) with no warn-only wording, and the /raw banner is
alert-danger.

DEV and QA ConfigDbs hold no Galaxy/OpcUaClient driver (checked 2026-11-13);
no production install exists.

Tests: GalaxyDriverLegacyCredentialTests replaced by
GalaxyDriverCredentialScopeTests; new OpcUaClientDriverCredentialScopeTests;
tracker tests in OpcUaClientSecretResolutionTests /
OpcUaClientUserCertificatePathTests deleted; AdminUI preflight tests drop the
Running/banner-model cases and assert Error level. Against the old code the
new refusal tests fail (Galaxy 5/6, OpcUaClient 4/5; the two positive controls
pass on both).

docs/security.md: the migration table now says running drivers refuse; the
warn-only section is replaced by "The compatibility window is over".

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 02:06:43 -05:00

169 lines
7.9 KiB
C#

using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments;
using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Deployments;
/// <summary>
/// lmxopcua#815 credential-reference preflight (a hard error since #834): stored (draft) and deployed configs with
/// out-of-namespace credential references are listed and logged as errors — driver, device, field, allowed form —
/// and never the value; nothing is resolved.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CredentialReferencePreflightTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private const string LegacyGalaxy = """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"env:MX_API_KEY"}}""";
private static string SeedLegacy()
{
var name = RawTreeTestDb.SeedFresh();
using var db = RawTreeTestDb.CreateNamed(name);
// Written straight to the DB, as a pre-#815 build would have — the save gate now refuses it.
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "gx-old", ClusterId = RawTreeTestDb.ClusterId, Name = "Galaxy main",
DriverType = DriverTypeNames.Galaxy, DriverConfig = LegacyGalaxy,
});
db.Devices.Add(new Device
{
DeviceId = "ouc-dev", DriverInstanceId = "gx-old", Name = "gw1",
DeviceConfig = """{"AlarmAck":{"FallbackPasswordSecretRef":"file:C:\\keys\\ack.txt"}}""",
});
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "mb", ClusterId = RawTreeTestDb.ClusterId, Name = "modbus",
DriverType = DriverTypeNames.Modbus, DriverConfig = """{"Password":"env:ConnectionStrings__ConfigDb"}""",
});
var artifact = """
{"DriverInstances":[{"DriverInstanceId":"ouc","Name":"Upstream","DriverType":"OpcUaClient",
"DriverConfig":"{\"Password\":\"secret:plant/opcua-pw\"}"}]}
""";
db.Deployments.Add(new Deployment
{
RevisionHash = "abc", CreatedBy = "t", Status = DeploymentStatus.Sealed, ArtifactBlob = Encoding.UTF8.GetBytes(artifact),
});
// A newer deployment that never sealed is not what the fleet runs — it must not mask the sealed one.
db.Deployments.Add(new Deployment
{
RevisionHash = "failed", CreatedBy = "t", Status = DeploymentStatus.PartiallyFailed,
CreatedAtUtc = DateTime.UtcNow.AddMinutes(5), ArtifactBlob = Encoding.UTF8.GetBytes("""{"DriverInstances":[]}"""),
});
db.SaveChanges();
return name;
}
[Fact]
public async Task Scan_lists_draft_rows_devices_and_the_deployed_artifact_without_values()
{
var findings = await new CredentialReferencePreflight(RawTreeTestDb.Factory(SeedLegacy())).ScanAsync(Ct);
findings.ShouldContain(f => f.Source == CredentialFindingSource.Draft && f.DriverName == "Galaxy main"
&& f.DeviceName == null && f.Problem.Contains("Gateway.ApiKeySecretRef")
&& f.Problem.Contains("env:OTOPCUA_GALAXY_<NAME>"));
findings.ShouldContain(f => f.Source == CredentialFindingSource.Draft && f.DeviceName == "gw1"
&& f.Problem.Contains("AlarmAck.FallbackPasswordSecretRef"));
findings.ShouldContain(f => f.Source == CredentialFindingSource.Deployed && f.DriverName == "Upstream"
&& f.Problem.Contains("secret:opcuaclient/<name>"));
findings.ShouldNotContain(f => f.DriverInstanceId == "mb"); // not a credential-resolving driver
findings.ShouldAllBe(f => !f.Problem.Contains("MX_API_KEY") && !f.Problem.Contains("plant/opcua-pw")
&& !f.Problem.Contains("ack.txt"));
}
[Fact]
public async Task A_clean_database_has_no_findings()
=> (await new CredentialReferencePreflight(RawTreeTestDb.Factory(RawTreeTestDb.SeedFresh())).ScanAsync(Ct)).ShouldBeEmpty();
[Fact]
public async Task The_boot_pass_logs_one_error_per_finding_and_publishes_the_state()
{
var services = new ServiceCollection()
.AddSingleton(RawTreeTestDb.Factory(SeedLegacy()))
.BuildServiceProvider();
var state = new CredentialReferencePreflightState();
var logger = new CaptureLogger();
var svc = new CredentialReferencePreflightService(services, new FakeLifetime(), state, logger) { StartDelay = TimeSpan.Zero };
var findings = await svc.RunOnceAsync(Ct);
findings.Count.ShouldBe(3);
state.Current.Findings.Count.ShouldBe(3);
state.Current.ScannedAtUtc.ShouldNotBeNull();
var errors = logger.Entries.Where(e => e.Level == LogLevel.Error).ToList();
errors.Count.ShouldBe(3);
errors.ShouldAllBe(w => w.Message.Contains("lmxopcua#815") && !w.Message.Contains("MX_API_KEY"));
errors.ShouldContain(w => w.Message.Contains("Galaxy main") && w.Message.Contains("Gateway.ApiKeySecretRef"));
// #834: no warn-only wording survives — a running driver refuses these.
errors.ShouldAllBe(w => !w.Message.Contains("warn-only") && w.Message.Contains("refuses it at resolve time"));
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
}
[Fact]
public async Task A_node_without_a_config_db_skips_quietly()
{
var svc = new CredentialReferencePreflightService(
new ServiceCollection().BuildServiceProvider(), new FakeLifetime(), new CredentialReferencePreflightState(), new CaptureLogger());
(await svc.RunOnceAsync(Ct)).ShouldBeEmpty();
}
[Fact]
public async Task The_hosted_service_waits_for_ApplicationStarted_before_scanning()
{
var services = new ServiceCollection().AddSingleton(RawTreeTestDb.Factory(SeedLegacy())).BuildServiceProvider();
var lifetime = new FakeLifetime();
var state = new CredentialReferencePreflightState();
var svc = new CredentialReferencePreflightService(services, lifetime, state, new CaptureLogger()) { StartDelay = TimeSpan.Zero };
await svc.StartAsync(Ct);
await Task.Delay(100, Ct);
state.Current.ScannedAtUtc.ShouldBeNull(); // nothing before the host has started
lifetime.Start();
var deadline = DateTime.UtcNow.AddSeconds(10);
while (state.Current.ScannedAtUtc is null && DateTime.UtcNow < deadline) await Task.Delay(20, Ct);
state.Current.Findings.Count.ShouldBe(3);
await svc.StopAsync(Ct);
}
private sealed class FakeLifetime : IHostApplicationLifetime
{
private readonly CancellationTokenSource _started = new();
public CancellationToken ApplicationStarted => _started.Token;
public CancellationToken ApplicationStopping => CancellationToken.None;
public CancellationToken ApplicationStopped => CancellationToken.None;
public void Start() => _started.Cancel();
public void StopApplication()
{
}
}
private sealed class CaptureLogger : ILogger<CredentialReferencePreflightService>
{
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)));
}
}
}