f0c6cf9640
v2-ci / unit-tests (push) Has been cancelled
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
169 lines
7.9 KiB
C#
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)));
|
|
}
|
|
}
|
|
}
|