Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverCredentialScopeTests.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

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);
}
}
}