fix(opcuaserver-tests): supply client cert-store paths + adapt to SDK read-throws
v2-ci / build (pull_request) Successful in 7m23s
v2-ci / unit-tests (pull_request) Failing after 15m33s

OpcUaServer.IntegrationTests was 4F. Two distinct, previously-masked issues:

1. Cert-store gap: the in-test client SecurityConfiguration used a bare
   new SecurityConfiguration()/CertificateIdentifier(), so ValidateAsync threw
   'TrustedIssuerCertificates StorePath must be specified' before any connect.
   Added TestClientSecurity helper building Directory own/issuer/trusted/rejected
   stores under a throwaway temp PKI dir (mirrors DefaultApplicationConfigurationFactory);
   wired into DualEndpointTests + SubscriptionSurvivalTests.

2. Fixing #1 unmasked an SDK-version drift: the 1.5.378 node-cache read path
   throws ServiceResultException(BadNodeIdUnknown) for a removed node instead of
   returning a bad DataValue. The two subscription-survival assertions now expect
   the throw via Should.ThrowAsync. Behavior under test (removed node -> unknown)
   is unchanged; only the delivery mechanism differs.

Suite 4F -> 4/4 green. Integration-sweep follow-up #5.
This commit is contained in:
Joseph Doherty
2026-07-15 03:34:14 -04:00
parent c1482802d4
commit de9d9697fd
4 changed files with 108 additions and 44 deletions
+15 -5
View File
@@ -27,7 +27,7 @@ need sudo to create new ones — I deployed FOCAS to `~/otopcua-focas` on the ho
| **AbCip** | ✅ 10P/3skip (was 6F/1P/3skip) | static-init-order bug FIXED 2026-07-15 (`326b22a7`) — see below | | **AbCip** | ✅ 10P/3skip (was 6F/1P/3skip) | static-init-order bug FIXED 2026-07-15 (`326b22a7`) — see below |
| **FOCAS** | ❌ 9F/1P | tests assume a localhost-colocated mock — see below | | **FOCAS** | ❌ 9F/1P | tests assume a localhost-colocated mock — see below |
| **AbLegacy** | ❌ 4F | no AbLegacy sim fixture deployed to the host | | **AbLegacy** | ❌ 4F | no AbLegacy sim fixture deployed to the host |
| **OpcUaServer** | 4F | test-harness cert-store config gap | | **OpcUaServer** | ✅ 4/4 (was 4F) | cert-store gap + SDK read-throws drift FIXED 2026-07-15 — see below |
### Headline conclusion ### Headline conclusion
**No OtOpcUa production regression.** Every failure is **pre-existing test/fixture infrastructure rot** in **No OtOpcUa production regression.** Every failure is **pre-existing test/fixture infrastructure rot** in
@@ -66,9 +66,13 @@ and never run, so they've bit-rotted in several distinct ways.
- **AbLegacy** — 4F on `StatusCode` because **no AbLegacy sim exists on the host** (no `/opt/otopcua-ablegacy`, - **AbLegacy** — 4F on `StatusCode` because **no AbLegacy sim exists on the host** (no `/opt/otopcua-ablegacy`,
none deployed). Reads return Bad. **Fix:** deploy an AbLegacy fixture (check `tests/.../AbLegacy.IntegrationTests/Docker/`). none deployed). Reads return Bad. **Fix:** deploy an AbLegacy fixture (check `tests/.../AbLegacy.IntegrationTests/Docker/`).
- **OpcUaServer** — 4F on `Opc.Ua.ServiceResultException : TrustedIssuerCertificates StorePath must be - **OpcUaServer** — **FIXED 2026-07-15** (PR #447). Was 4F on `Opc.Ua.ServiceResultException :
specified` — the in-test OPC UA server config omits a cert-store path. Test-harness config gap, not a TrustedIssuerCertificates StorePath must be specified` — the in-test *client* `SecurityConfiguration`
fixture. **Fix:** supply the trusted-issuer StorePath in the harness's `ApplicationConfiguration`. omitted the trusted-issuer/peer/rejected store paths that `ValidateAsync` requires. Fix: `TestClientSecurity`
helper rooting Directory stores at a throwaway PKI dir (mirrors `DefaultApplicationConfigurationFactory`).
Fixing it unmasked a second drift — the 1.5.378 node-cache read path throws
`ServiceResultException(BadNodeIdUnknown)` for a removed node rather than returning a bad `DataValue`;
the two subscription-survival assertions now use `Should.ThrowAsync`. Suite **4F → 4/4 green**.
- **Host** — largely healthy (105/117). 7 failures are the heavy 2-node deploy/failover/reconnect E2E tests - **Host** — largely healthy (105/117). 7 failures are the heavy 2-node deploy/failover/reconnect E2E tests
(`DeployHappyPathTests`, `FailoverDuringDeployTests`, `DriverReconnectE2eTests`, (`DeployHappyPathTests`, `FailoverDuringDeployTests`, `DriverReconnectE2eTests`,
@@ -92,7 +96,13 @@ and never run, so they've bit-rotted in several distinct ways.
2. **Modbus non-standard profiles** — pin pymodbus in the fixture Dockerfile (or update expected values for 4.x). 2. **Modbus non-standard profiles** — pin pymodbus in the fixture Dockerfile (or update expected values for 4.x).
3. **FOCAS** — reconcile the 127.0.0.1-mock topology with the remote-fixture model. 3. **FOCAS** — reconcile the 127.0.0.1-mock topology with the remote-fixture model.
4. **AbLegacy** — deploy an AbLegacy sim fixture (none on host). 4. **AbLegacy** — deploy an AbLegacy sim fixture (none on host).
5. **OpcUaServer** — supply cert-store StorePath in the harness config. 5. ~~**OpcUaServer** — supply cert-store StorePath in the harness config.~~
✅ **DONE 2026-07-15** (PR #447). Added `TestClientSecurity` helper building a validated client
`SecurityConfiguration` with Directory own/issuer/trusted/rejected stores under a throwaway PKI dir
(mirrors `DefaultApplicationConfigurationFactory`); wired into both `DualEndpointTests` +
`SubscriptionSurvivalTests`. That unmasked a **second** drift: the 1.5.378 node-cache read path
*throws* `ServiceResultException(BadNodeIdUnknown)` for a removed node instead of returning a bad
`DataValue` — updated the two subscription-survival assertions to `Should.ThrowAsync`. Suite **4F → 4/4**.
6. **Host** — replace `bitnami/openldap:2.6` (image gone); run E2E on real SQL or raise deadlines. 6. **Host** — replace `bitnami/openldap:2.6` (image gone); run E2E on real SQL or raise deadlines.
7. **Fixture deployment gap** — FOCAS + AbLegacy fixtures aren't on the host at `/opt/otopcua-*`; the sweep 7. **Fixture deployment gap** — FOCAS + AbLegacy fixtures aren't on the host at `/opt/otopcua-*`; the sweep
requires deploying them (and `/opt` needs sudo — used `~/otopcua-<driver>` for FOCAS). requires deploying them (and `/opt` needs sudo — used `~/otopcua-<driver>` for FOCAS).
@@ -72,40 +72,44 @@ public sealed class DualEndpointTests
// SDK 1.5.374 sync-style session-open path — mirrors src/Client/.../DefaultSessionFactory.cs // SDK 1.5.374 sync-style session-open path — mirrors src/Client/.../DefaultSessionFactory.cs
// and DefaultApplicationConfigurationFactory.cs. The 1.5.378 telemetry/async overloads are // and DefaultApplicationConfigurationFactory.cs. The 1.5.378 telemetry/async overloads are
// not available at this pinned version. // not available at this pinned version.
var appConfig = new ApplicationConfiguration var clientPki = TestClientSecurity.AllocatePkiRoot();
try
{ {
ApplicationName = "OtOpcUa.DualEndpointClient", var appConfig = new ApplicationConfiguration
ApplicationUri = $"urn:OtOpcUa.DualEndpointClient.{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{ {
ApplicationCertificate = new CertificateIdentifier(), ApplicationName = "OtOpcUa.DualEndpointClient",
AutoAcceptUntrustedCertificates = true, ApplicationUri = $"urn:OtOpcUa.DualEndpointClient.{Guid.NewGuid():N}",
}, ApplicationType = ApplicationType.Client,
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, SecurityConfiguration = TestClientSecurity.Build(clientPki),
}; ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
await appConfig.ValidateAsync(ApplicationType.Client, default); };
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; await appConfig.ValidateAsync(ApplicationType.Client, default);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
// 1.5.378: the discovery/session/read client surface moved to async. // 1.5.378: the discovery/session/read client surface moved to async.
var endpoint = await CoreClientUtils.SelectEndpointAsync( var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), default); appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), default);
var endpointConfiguration = EndpointConfiguration.Create(appConfig); var endpointConfiguration = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration); var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
using var session = await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( using var session = await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig, appConfig,
configuredEndpoint, configuredEndpoint,
updateBeforeConnect: false, updateBeforeConnect: false,
checkDomain: false, checkDomain: false,
sessionName: "DualEndpointTests", sessionName: "DualEndpointTests",
sessionTimeout: 60_000, sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()), identity: new UserIdentity(new AnonymousIdentityToken()),
preferredLocales: null, preferredLocales: null,
ct: default); ct: default);
var value = await session.ReadValueAsync(VariableIds.Server_ServerArray, default); var value = await session.ReadValueAsync(VariableIds.Server_ServerArray, default);
return (string[])value.Value; return (string[])value.Value;
}
finally
{
if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true);
}
} }
private static int AllocateFreePort() private static int AllocateFreePort()
@@ -215,9 +215,11 @@ public sealed class SubscriptionSurvivalTests
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5)); await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
// B is gone: a re-read returns BadNodeIdUnknown. // B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path
var bRead = await session.ReadValueAsync(nodeIdB, ct); // throws ServiceResultException for an unknown node rather than returning a bad DataValue.
bRead.StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown); var bReadEx = await Should.ThrowAsync<ServiceResultException>(
async () => await session.ReadValueAsync(nodeIdB, ct));
bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown);
} }
finally finally
{ {
@@ -296,9 +298,11 @@ public sealed class SubscriptionSurvivalTests
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5)); await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
// B is gone. // B is gone. (1.5.378 node-cache read throws for an unknown node — see above.)
var nodeIdB = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"), ns); var nodeIdB = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"), ns);
(await session.ReadValueAsync(nodeIdB, ct)).StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown); var bReadEx = await Should.ThrowAsync<ServiceResultException>(
async () => await session.ReadValueAsync(nodeIdB, ct));
bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown);
// C is browsable + readable. // C is browsable + readable.
var nodeIdC = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "C"), ns); var nodeIdC = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "C"), ns);
@@ -331,11 +335,10 @@ public sealed class SubscriptionSurvivalTests
ApplicationName = "OtOpcUa.SubscriptionSurvivalClient", ApplicationName = "OtOpcUa.SubscriptionSurvivalClient",
ApplicationUri = $"urn:OtOpcUa.SubscriptionSurvivalClient.{Guid.NewGuid():N}", ApplicationUri = $"urn:OtOpcUa.SubscriptionSurvivalClient.{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client, ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration // Directory cert stores rooted at a throwaway temp PKI dir — ValidateAsync rejects a
{ // bare SecurityConfiguration ("TrustedIssuerCertificates StorePath must be specified").
ApplicationCertificate = new CertificateIdentifier(), // The returned session outlives this method, so the dir is left for the OS temp sweep.
AutoAcceptUntrustedCertificates = true, SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
},
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
}; };
await appConfig.ValidateAsync(ApplicationType.Client, ct); await appConfig.ValidateAsync(ApplicationType.Client, ct);
@@ -0,0 +1,47 @@
using Opc.Ua;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Builds the client-side <see cref="SecurityConfiguration"/> the integration tests use to
/// open a real OPC UA session. The SDK's <c>ApplicationConfiguration.ValidateAsync</c> requires
/// the trusted-issuer / trusted-peer / rejected stores to carry an explicit <c>StorePath</c>
/// (it throws <c>TrustedIssuerCertificates StorePath must be specified</c> otherwise), so — unlike
/// a bare <c>new SecurityConfiguration()</c> — each store is rooted at a throwaway PKI directory,
/// mirroring <c>src/Client/.../DefaultApplicationConfigurationFactory.cs</c>.
/// </summary>
internal static class TestClientSecurity
{
/// <summary>Allocates a fresh throwaway client PKI root under the temp directory.</summary>
/// <returns>An absolute path that does not yet exist; the SDK creates the stores under it.</returns>
public static string AllocatePkiRoot() =>
Path.Combine(Path.GetTempPath(), $"otopcua-client-pki-{Guid.NewGuid():N}");
/// <summary>Builds an auto-accepting client security config with Directory stores under <paramref name="pkiRoot"/>.</summary>
/// <param name="pkiRoot">Root directory for the own / issuer / trusted / rejected stores.</param>
/// <returns>A validated-ready <see cref="SecurityConfiguration"/>.</returns>
public static SecurityConfiguration Build(string pkiRoot) => new()
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "own"),
},
TrustedIssuerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "issuer"),
},
TrustedPeerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "trusted"),
},
RejectedCertificateStore = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "rejected"),
},
AutoAcceptUntrustedCertificates = true,
};
}