diff --git a/archreview/plans/INTEGRATION-SWEEP-STATUS.md b/archreview/plans/INTEGRATION-SWEEP-STATUS.md index 463cc298..2cbaf4e4 100644 --- a/archreview/plans/INTEGRATION-SWEEP-STATUS.md +++ b/archreview/plans/INTEGRATION-SWEEP-STATUS.md @@ -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 | | **FOCAS** | ❌ 9F/1P | tests assume a localhost-colocated mock — see below | | **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 **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`, 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 - specified` — the in-test OPC UA server config omits a cert-store path. Test-harness config gap, not a - fixture. **Fix:** supply the trusted-issuer StorePath in the harness's `ApplicationConfiguration`. +- **OpcUaServer** — ✅ **FIXED 2026-07-15** (PR #447). Was 4F on `Opc.Ua.ServiceResultException : + TrustedIssuerCertificates StorePath must be specified` — the in-test *client* `SecurityConfiguration` + 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 (`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). 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). -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. 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-` for FOCAS). diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs index 8de20862..5a27863b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs @@ -72,40 +72,44 @@ public sealed class DualEndpointTests // 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 // not available at this pinned version. - var appConfig = new ApplicationConfiguration + var clientPki = TestClientSecurity.AllocatePkiRoot(); + try { - ApplicationName = "OtOpcUa.DualEndpointClient", - ApplicationUri = $"urn:OtOpcUa.DualEndpointClient.{Guid.NewGuid():N}", - ApplicationType = ApplicationType.Client, - SecurityConfiguration = new SecurityConfiguration + var appConfig = new ApplicationConfiguration { - ApplicationCertificate = new CertificateIdentifier(), - AutoAcceptUntrustedCertificates = true, - }, - ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, - }; - await appConfig.ValidateAsync(ApplicationType.Client, default); - appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + ApplicationName = "OtOpcUa.DualEndpointClient", + ApplicationUri = $"urn:OtOpcUa.DualEndpointClient.{Guid.NewGuid():N}", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = TestClientSecurity.Build(clientPki), + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, + }; + 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. - var endpoint = await CoreClientUtils.SelectEndpointAsync( - appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), default); - var endpointConfiguration = EndpointConfiguration.Create(appConfig); - var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration); + // 1.5.378: the discovery/session/read client surface moved to async. + var endpoint = await CoreClientUtils.SelectEndpointAsync( + appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), default); + var endpointConfiguration = EndpointConfiguration.Create(appConfig); + var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration); - using var session = await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( - appConfig, - configuredEndpoint, - updateBeforeConnect: false, - checkDomain: false, - sessionName: "DualEndpointTests", - sessionTimeout: 60_000, - identity: new UserIdentity(new AnonymousIdentityToken()), - preferredLocales: null, - ct: default); + using var session = await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( + appConfig, + configuredEndpoint, + updateBeforeConnect: false, + checkDomain: false, + sessionName: "DualEndpointTests", + sessionTimeout: 60_000, + identity: new UserIdentity(new AnonymousIdentityToken()), + preferredLocales: null, + ct: default); - var value = await session.ReadValueAsync(VariableIds.Server_ServerArray, default); - return (string[])value.Value; + var value = await session.ReadValueAsync(VariableIds.Server_ServerArray, default); + return (string[])value.Value; + } + finally + { + if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true); + } } private static int AllocateFreePort() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs index 432c77cb..979b33f6 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs @@ -215,9 +215,11 @@ public sealed class SubscriptionSurvivalTests 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)); - // B is gone: a re-read returns BadNodeIdUnknown. - var bRead = await session.ReadValueAsync(nodeIdB, ct); - bRead.StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown); + // B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path + // throws ServiceResultException for an unknown node rather than returning a bad DataValue. + var bReadEx = await Should.ThrowAsync( + async () => await session.ReadValueAsync(nodeIdB, ct)); + bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown); } finally { @@ -296,9 +298,11 @@ public sealed class SubscriptionSurvivalTests 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)); - // 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); - (await session.ReadValueAsync(nodeIdB, ct)).StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown); + var bReadEx = await Should.ThrowAsync( + async () => await session.ReadValueAsync(nodeIdB, ct)); + bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown); // C is browsable + readable. var nodeIdC = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "C"), ns); @@ -331,11 +335,10 @@ public sealed class SubscriptionSurvivalTests ApplicationName = "OtOpcUa.SubscriptionSurvivalClient", ApplicationUri = $"urn:OtOpcUa.SubscriptionSurvivalClient.{Guid.NewGuid():N}", ApplicationType = ApplicationType.Client, - SecurityConfiguration = new SecurityConfiguration - { - ApplicationCertificate = new CertificateIdentifier(), - AutoAcceptUntrustedCertificates = true, - }, + // Directory cert stores rooted at a throwaway temp PKI dir — ValidateAsync rejects a + // bare SecurityConfiguration ("TrustedIssuerCertificates StorePath must be specified"). + // The returned session outlives this method, so the dir is left for the OS temp sweep. + SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()), ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, }; await appConfig.ValidateAsync(ApplicationType.Client, ct); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/TestClientSecurity.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/TestClientSecurity.cs new file mode 100644 index 00000000..b862277c --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/TestClientSecurity.cs @@ -0,0 +1,47 @@ +using Opc.Ua; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// Builds the client-side the integration tests use to +/// open a real OPC UA session. The SDK's ApplicationConfiguration.ValidateAsync requires +/// the trusted-issuer / trusted-peer / rejected stores to carry an explicit StorePath +/// (it throws TrustedIssuerCertificates StorePath must be specified otherwise), so — unlike +/// a bare new SecurityConfiguration() — each store is rooted at a throwaway PKI directory, +/// mirroring src/Client/.../DefaultApplicationConfigurationFactory.cs. +/// +internal static class TestClientSecurity +{ + /// Allocates a fresh throwaway client PKI root under the temp directory. + /// An absolute path that does not yet exist; the SDK creates the stores under it. + public static string AllocatePkiRoot() => + Path.Combine(Path.GetTempPath(), $"otopcua-client-pki-{Guid.NewGuid():N}"); + + /// Builds an auto-accepting client security config with Directory stores under . + /// Root directory for the own / issuer / trusted / rejected stores. + /// A validated-ready . + 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, + }; +}