using Opc.Ua; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// #273 subscription tuning, #277 revocation options, #278 discovery URL. [Trait("Category", "Unit")] public sealed class OpcUaClientTuningAndDiscoveryTests { // ---- #273 ---- [Fact] public void Defaults_equal_the_values_that_used_to_be_hardcoded() { var o = new OpcUaClientDriverOptions(); o.SubscriptionKeepAliveCount.ShouldBe(10u); o.SubscriptionLifetimeCount.ShouldBe(1000u); o.SubscriptionMaxNotificationsPerPublish.ShouldBe(0u); o.SubscriptionPriority.ShouldBe((byte)0); o.SubscriptionPublishingInterval.ShouldBeNull(); o.DiscoveryUrl.ShouldBeNull(); o.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeFalse(); o.CertificateValidation.IssuerStorePath.ShouldBeNull(); Should.NotThrow(() => OpcUaClientDriver.ValidateTuning(o)); } [Fact] public void Factory_json_binds_the_new_keys_and_old_json_keeps_defaults() { var o = OpcUaClientDriverFactoryExtensions.ParseOptions("d", """ {"subscriptionPublishingInterval":"00:00:02","subscriptionKeepAliveCount":20, "subscriptionLifetimeCount":600,"subscriptionMaxNotificationsPerPublish":500, "subscriptionPriority":9, "discoveryUrl":"opc.tcp://lds:4840","discoveryServerUri":"urn:plant:l1", "certificateValidation":{"rejectUnknownRevocationStatus":true,"issuerStorePath":"/pki/issuers"}} """); o.SubscriptionPublishingInterval.ShouldBe(TimeSpan.FromSeconds(2)); o.SubscriptionKeepAliveCount.ShouldBe(20u); o.SubscriptionLifetimeCount.ShouldBe(600u); o.SubscriptionMaxNotificationsPerPublish.ShouldBe(500u); o.SubscriptionPriority.ShouldBe((byte)9); o.DiscoveryUrl.ShouldBe("opc.tcp://lds:4840"); o.DiscoveryServerUri.ShouldBe("urn:plant:l1"); o.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeTrue(); o.CertificateValidation.IssuerStorePath.ShouldBe("/pki/issuers"); var legacy = OpcUaClientDriverFactoryExtensions.ParseOptions("d", """{"endpointUrl":"opc.tcp://x:1"}"""); legacy.SubscriptionKeepAliveCount.ShouldBe(10u); legacy.SubscriptionLifetimeCount.ShouldBe(1000u); } [Theory] [InlineData(0u, 1000u)] // keep-alive must be >= 1 [InlineData(10u, 29u)] // lifetime < 3x keep-alive [InlineData(500u, 1000u)] public void ValidateTuning_rejects_out_of_range_counts(uint keepAlive, uint lifetime) { var o = new OpcUaClientDriverOptions { SubscriptionKeepAliveCount = keepAlive, SubscriptionLifetimeCount = lifetime }; Should.Throw(() => OpcUaClientDriver.ValidateTuning(o)); } [Fact] public void ValidateTuning_accepts_the_3x_boundary_and_rejects_bad_interval_or_discovery_url() { Should.NotThrow(() => OpcUaClientDriver.ValidateTuning( new OpcUaClientDriverOptions { SubscriptionKeepAliveCount = 10, SubscriptionLifetimeCount = 30 })); Should.Throw(() => OpcUaClientDriver.ValidateTuning( new OpcUaClientDriverOptions { SubscriptionPublishingInterval = TimeSpan.Zero })); Should.Throw(() => OpcUaClientDriver.ValidateTuning( new OpcUaClientDriverOptions { DiscoveryUrl = "not a uri" })); } [Theory] [InlineData(null, 1000, 1000)] // no override: the per-call value [InlineData(null, 10, 50)] // floor still applies to the per-call value [InlineData(2000, 250, 2000)] // override wins over the per-call value [InlineData(5, 250, 50)] // ... and is floored too public void ResolvePublishingIntervalMs_prefers_the_override_and_floors_at_50(int? overrideMs, int requestedMs, int expected) { var o = new OpcUaClientDriverOptions { SubscriptionPublishingInterval = overrideMs is { } ms ? TimeSpan.FromMilliseconds(ms) : null, }; OpcUaClientDriver.ResolvePublishingIntervalMs(o, TimeSpan.FromMilliseconds(requestedMs)).ShouldBe(expected); } // ---- #277 ---- [Fact] public void Revocation_failures_are_exactly_Revoked_and_IssuerRevoked() { OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateRevoked).ShouldBeTrue(); OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateIssuerRevoked).ShouldBeTrue(); // "unknown" is governed by RejectUnknownRevocationStatus, not auto-accept's hard exception; // untrusted is what auto-accept exists to wave through. OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateRevocationUnknown).ShouldBeFalse(); OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateUntrusted).ShouldBeFalse(); OpcUaClientDriver.IsRevocationFailure(StatusCodes.Good).ShouldBeFalse(); } // ---- #278 ---- private static EndpointDescription Ep(string url, string policyUri, MessageSecurityMode mode) => new() { EndpointUrl = url, SecurityPolicyUri = policyUri, SecurityMode = mode }; [Fact] public void ResolveEndpointCandidates_without_discovery_is_unchanged() { var o = new OpcUaClientDriverOptions { EndpointUrls = ["opc.tcp://a:1", "opc.tcp://b:2"] }; OpcUaClientDriver.ResolveEndpointCandidates(o, []).ShouldBe(["opc.tcp://a:1", "opc.tcp://b:2"]); OpcUaClientDriver.ResolveEndpointCandidates(o).ShouldBe(["opc.tcp://a:1", "opc.tcp://b:2"]); } [Fact] public void ResolveEndpointCandidates_puts_configured_endpoints_before_discovered_and_dedups() { var o = new OpcUaClientDriverOptions { EndpointUrls = ["opc.tcp://Static:1", "opc.tcp://found:2"] }; var merged = OpcUaClientDriver.ResolveEndpointCandidates(o, ["opc.tcp://found:2", "opc.tcp://new:3"]); merged.ShouldBe(["opc.tcp://Static:1", "opc.tcp://found:2", "opc.tcp://new:3"]); } [Fact] public void ResolveEndpointCandidates_uses_the_single_EndpointUrl_before_discovered_when_no_list() { var o = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://only:9" }; OpcUaClientDriver.ResolveEndpointCandidates(o, ["opc.tcp://d:1"]).ShouldBe(["opc.tcp://only:9", "opc.tcp://d:1"]); } private static OpcUaClientDriver.DiscoveredEndpoint De(string appUri, string url) => new(appUri, url); [Fact] public void SelectDiscoveredUrls_with_one_distinct_server_uses_it_and_dedups() { var urls = OpcUaClientDriver.SelectDiscoveredUrls( [De("urn:a", "opc.tcp://a:1"), De("urn:a", "opc.tcp://A:1"), De("urn:a", "opc.tcp://a:2")], null); urls.ShouldBe(["opc.tcp://a:1", "opc.tcp://a:2"]); } [Fact] public void SelectDiscoveredUrls_with_several_servers_and_no_filter_fails_naming_them() { var ex = Should.Throw(() => OpcUaClientDriver.SelectDiscoveredUrls( [De("urn:line1", "opc.tcp://l1:1"), De("urn:line2", "opc.tcp://l2:1")], null)); ex.Message.ShouldContain("urn:line1"); ex.Message.ShouldContain("urn:line2"); ex.Message.ShouldContain("DiscoveryServerUri"); } [Fact] public void SelectDiscoveredUrls_filter_picks_only_that_server_even_when_several_are_listed() { var urls = OpcUaClientDriver.SelectDiscoveredUrls( [De("urn:line1", "opc.tcp://l1:1"), De("urn:line2", "opc.tcp://l2:1"), De("urn:line2", "opc.tcp://l2:2")], "urn:line2"); urls.ShouldBe(["opc.tcp://l2:1", "opc.tcp://l2:2"]); } [Fact] public void SelectDiscoveredUrls_filter_matching_nothing_yields_empty_not_another_server() { OpcUaClientDriver.SelectDiscoveredUrls([De("urn:line1", "opc.tcp://l1:1")], "urn:other").ShouldBeEmpty(); OpcUaClientDriver.SelectDiscoveredUrls([], null).ShouldBeEmpty(); } // ---- #273: validation at options load ---- [Theory] [InlineData("""{"subscriptionKeepAliveCount":0}""")] [InlineData("""{"subscriptionKeepAliveCount":10,"subscriptionLifetimeCount":29}""")] [InlineData("""{"discoveryUrl":"not a uri"}""")] public void ParseOptions_rejects_out_of_range_tuning_with_a_clear_message(string json) { var ex = Should.Throw( () => OpcUaClientDriverFactoryExtensions.ParseOptions("drv-bad", json)); ex.Message.ShouldContain("drv-bad"); ex.Message.ShouldContain("invalid"); } [Fact] public void Factory_CreateInstance_fails_fast_on_a_bad_tuning_config() { Should.Throw(() => OpcUaClientDriverFactoryExtensions.CreateInstance( "drv-bad", """{"subscriptionLifetimeCount":5}""")); } [Fact] public void FilterEndpointUrls_keeps_only_the_wanted_policy_and_mode() { const string b256 = "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256"; const string none = "http://opcfoundation.org/UA/SecurityPolicy#None"; var endpoints = new[] { Ep("opc.tcp://h:1/none", none, MessageSecurityMode.None), Ep("opc.tcp://h:1/sign", b256, MessageSecurityMode.Sign), Ep("opc.tcp://h:1/enc", b256, MessageSecurityMode.SignAndEncrypt), Ep("", b256, MessageSecurityMode.SignAndEncrypt), }; OpcUaClientDriver.FilterEndpointUrls(endpoints, b256, MessageSecurityMode.SignAndEncrypt) .ShouldBe(["opc.tcp://h:1/enc"]); OpcUaClientDriver.FilterEndpointUrls(endpoints, none, MessageSecurityMode.None) .ShouldBe(["opc.tcp://h:1/none"]); } }