1219b7b13e
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
217 lines
9.5 KiB
C#
217 lines
9.5 KiB
C#
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
|
|
|
/// <summary>#273 subscription tuning, #277 revocation options, #278 discovery URL.</summary>
|
|
[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<InvalidOperationException>(() => 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<InvalidOperationException>(() => OpcUaClientDriver.ValidateTuning(
|
|
new OpcUaClientDriverOptions { SubscriptionPublishingInterval = TimeSpan.Zero }));
|
|
Should.Throw<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(
|
|
() => 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<InvalidOperationException>(() => 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"]);
|
|
}
|
|
}
|