4563940792
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>
178 lines
8.0 KiB
C#
178 lines
8.0 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.Commons.Messages.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Deployments;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </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", ArtifactBlob = Encoding.UTF8.GetBytes(artifact) });
|
|
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_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<string, string>
|
|
{
|
|
[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<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)));
|
|
}
|
|
}
|
|
}
|