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.Commons.Messages.Drivers; 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; /// /// lmxopcua#815 upgrade preflight: stored (draft) and deployed configs with out-of-namespace credential references /// are listed and logged — driver, device, field, allowed form — and never the value; nothing is resolved. /// [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_")); 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/")); 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_warning_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 warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList(); warnings.Count.ShouldBe(3); warnings.ShouldAllBe(w => w.Message.Contains("lmxopcua#815") && !w.Message.Contains("MX_API_KEY")); warnings.ShouldContain(w => w.Message.Contains("Galaxy main") && w.Message.Contains("Gateway.ApiKeySecretRef")); } [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); } [Fact] public void Running_drivers_reporting_warn_only_resolution_are_surfaced_from_driver_health() { var snapshots = new[] { new DriverHealthChanged("c", "gx-old", "Healthy", null, null, 0, DateTime.UtcNow, Diagnostics: new Dictionary { [LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "Gateway.ApiKeySecretRef"] = "deprecated …", ["s7.pdu"] = "480", }), new DriverHealthChanged("c", "mb", "Healthy", null, null, 0, DateTime.UtcNow), }; var running = CredentialReferencePreflight.RunningFindings(snapshots); running.ShouldHaveSingleItem().ShouldSatisfyAllConditions( f => f.Source.ShouldBe(CredentialFindingSource.Running), f => f.DriverInstanceId.ShouldBe("gx-old"), f => f.Problem.ShouldStartWith("Gateway.ApiKeySecretRef")); } 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 { public List<(LogLevel Level, string Message)> Entries { get; } = new(); public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { lock (Entries) Entries.Add((logLevel, formatter(state, exception))); } } private static DriverHealthChanged LegacyRunning(string node) => new("c", "gx-old", "Healthy", null, null, 0, DateTime.UtcNow, Diagnostics: new Dictionary { [LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "Gateway.ApiKeySecretRef"] = "deprecated", }, ReportingNodeId: node); [Fact] public void Banner_keeps_running_findings_when_the_config_db_scan_failed_847() { // lmxopcua#847: a failed ConfigDb scan must not hide what the live health feed says is still running. var failed = new CredentialReferencePreflightState.Snapshot([], DateTime.UtcNow, "could not read the config DB."); var model = CredentialReferenceBannerModel.Compose(failed, [LegacyRunning("central-1")]); model.Visible.ShouldBeTrue(); model.ScanError.ShouldNotBeNull(); model.ScanFindings.ShouldBeEmpty(); model.RunningFindings.ShouldHaveSingleItem().DriverInstanceId.ShouldBe("gx-old"); } [Fact] public void Banner_shows_a_scan_error_alone_and_hides_when_nothing_to_say_847() { CredentialReferenceBannerModel.Compose(new([], DateTime.UtcNow, "db down"), []).ShouldSatisfyAllConditions( m => m.Visible.ShouldBeTrue(), m => m.RunningFindings.ShouldBeEmpty()); CredentialReferenceBannerModel.Compose(new([], DateTime.UtcNow, null), []).Visible.ShouldBeFalse(); } [Fact] public void Banner_separates_scan_from_running_findings_and_dedups_the_redundant_pair_847() { var draft = new CredentialReferenceFinding(CredentialFindingSource.Draft, "Galaxy", "gx", "gx", null, "p"); var model = CredentialReferenceBannerModel.Compose( new([draft], DateTime.UtcNow, null), [LegacyRunning("central-1"), LegacyRunning("central-2")]); model.ScanFindings.ShouldHaveSingleItem().ShouldBe(draft); // Both nodes run the driver and report the same thing — one line, not two. model.RunningFindings.ShouldHaveSingleItem(); model.ScanError.ShouldBeNull(); } }