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
114 lines
4.6 KiB
C#
114 lines
4.6 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
|
|
|
/// <summary>
|
|
/// lmxopcua#834 (follow-up to #815 / #836): the warn-only compatibility window is over. A RUNNING OpcUaClient
|
|
/// driver whose stored config carries an out-of-namespace credential reference, or a user-certificate path outside
|
|
/// the node's certificate directory, now refuses it at resolve time in the session open
|
|
/// (<see cref="CredentialReferenceException"/>, before any store / environment read and before any endpoint is
|
|
/// dialled) — exactly as save, deploy, Test Connect and browse already did.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class OpcUaClientDriverCredentialScopeTests
|
|
{
|
|
private const string PlainPassword = "resolved-plaintext";
|
|
|
|
private static OpcUaClientDriverOptions Username(string password) => new()
|
|
{
|
|
EndpointUrl = "opc.tcp://127.0.0.1:1",
|
|
AuthType = OpcUaAuthType.Username,
|
|
Username = "plant-user",
|
|
Password = password,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Out-of-namespace values that the pre-#834 running driver resolved with the old semantics: a <c>secret:</c>
|
|
/// outside <c>opcuaclient/</c>, a case-variant <c>SECRET:</c> prefix, and an <c>env:</c> outside
|
|
/// <c>OTOPCUA_OPCUACLIENT_</c> (which it sent verbatim as a literal password).
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("secret:otopcua/configdb-password")]
|
|
[InlineData("SECRET:opcuaclient/inst/password")]
|
|
[InlineData("env:NOT_A_REF")]
|
|
public async Task A_running_driver_refuses_an_out_of_namespace_password_ref_before_reading_or_dialling(string reference)
|
|
{
|
|
var store = new CountingResolver();
|
|
using var driver = new OpcUaClientDriver(Username(reference), "ouc-834", secretResolver: store);
|
|
var opened = 0;
|
|
driver.OpenSessionForTest = (_, _) =>
|
|
{
|
|
opened++;
|
|
throw new InvalidOperationException("no server in this test");
|
|
};
|
|
|
|
var ex = await Should.ThrowAsync<CredentialReferenceException>(
|
|
() => driver.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
|
|
|
ex.Message.ShouldContain("Password");
|
|
ex.Message.ShouldNotContain(PlainPassword);
|
|
store.Calls.ShouldBe(0);
|
|
opened.ShouldBe(0, "the credential is refused before any endpoint is dialled");
|
|
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
|
}
|
|
|
|
/// <summary>lmxopcua#836: a fully-qualified (pre-#836) user-certificate path is refused, not loaded.</summary>
|
|
[Fact]
|
|
public async Task A_running_driver_refuses_an_absolute_user_certificate_path()
|
|
{
|
|
var abs = Path.Combine(Path.GetTempPath(), $"ouc-834-{Guid.NewGuid():N}.pfx");
|
|
var options = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = "opc.tcp://127.0.0.1:1",
|
|
AuthType = OpcUaAuthType.Certificate,
|
|
UserCertificatePath = abs,
|
|
UserCertificatePassword = "pw",
|
|
};
|
|
using var driver = new OpcUaClientDriver(options, "ouc-834-cert", secretResolver: new CountingResolver());
|
|
var opened = 0;
|
|
driver.OpenSessionForTest = (_, _) =>
|
|
{
|
|
opened++;
|
|
throw new InvalidOperationException("no server in this test");
|
|
};
|
|
|
|
await Should.ThrowAsync<CredentialReferenceException>(
|
|
() => driver.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
|
opened.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>Positive control: a namespaced reference resolves and the session open is reached.</summary>
|
|
[Fact]
|
|
public async Task A_namespaced_password_ref_resolves_and_the_session_open_is_reached()
|
|
{
|
|
var store = new CountingResolver();
|
|
using var driver = new OpcUaClientDriver(
|
|
Username("secret:opcuaclient/inst/password"), "ouc-834-ok", secretResolver: store);
|
|
var opened = 0;
|
|
driver.OpenSessionForTest = (_, _) =>
|
|
{
|
|
opened++;
|
|
throw new InvalidOperationException("no server in this test");
|
|
};
|
|
|
|
await Should.ThrowAsync<AggregateException>(
|
|
() => driver.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
|
store.Calls.ShouldBe(1);
|
|
opened.ShouldBe(1);
|
|
}
|
|
|
|
private sealed class CountingResolver : ISecretResolver
|
|
{
|
|
public int Calls { get; private set; }
|
|
|
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct)
|
|
{
|
|
Calls++;
|
|
return Task.FromResult<string?>(PlainPassword);
|
|
}
|
|
}
|
|
}
|