f127f45428
v2-ci / unit-tests (push) Has been cancelled
OpcUaApplicationHost left ServerConfiguration.MaxRegistrationInterval at the SDK default (30000) with a null RegistrationEndpoint, so SDK 2.0.1's StandardServer armed its registration timer against opc.tcp://localhost:4840, which on an OtOpcUa node is the server itself (no LDS). It answered BadServiceUnsupported and the SDK retried forever, visible since #874. Registration is now opt-in: ApplyDiscoveryRegistration sets MaxRegistrationInterval = 0 (the value that keeps StandardServer from arming the timer) unless OpcUa:DiscoveryServerUrl is set, in which case the server registers against that URL every OpcUa:DiscoveryRegistrationIntervalMs (default 30000) over SignAndEncrypt/Basic256Sha256, like the SDK's own default endpoint. The validator requires an absolute opc.tcp:// URL and a positive interval when the URL is set. Documented in Configuration.md and OpcUaServer.md. Co-Authored-By: Claude <noreply@anthropic.com>
135 lines
5.6 KiB
C#
135 lines
5.6 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Task 4 — verifies the net-new <see cref="OpcUaApplicationHostOptionsValidator"/> (built on the
|
|
/// shared <c>ZB.MOM.WW.Configuration</c> <c>OptionsValidatorBase</c>/<c>ValidationBuilder</c>) that
|
|
/// gates the OPC UA host options at startup. The C# defaults are all valid so a host with no
|
|
/// explicit <c>"OpcUa"</c> section still passes; the validator exists to reject explicit
|
|
/// prod/env overrides. Failure messages carry the real <c>"OpcUa:"</c> section prefix and the
|
|
/// exact shared-primitive wording so they read correctly when surfaced via <c>ValidateOnStart</c>.
|
|
/// </summary>
|
|
public sealed class OpcUaApplicationHostOptionsValidatorTests
|
|
{
|
|
private static readonly OpcUaApplicationHostOptionsValidator Sut = new();
|
|
|
|
/// <summary>The C# defaults (the as-bound shape when the section is absent) pass validation.</summary>
|
|
[Fact]
|
|
public void Default_options_succeed()
|
|
{
|
|
Sut.Validate(null, new OpcUaApplicationHostOptions()).Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>A port of 0 reports the shared port-range failure with the OpcUa prefix.</summary>
|
|
[Fact]
|
|
public void Zero_port_fails()
|
|
{
|
|
var result = Sut.Validate(null, new OpcUaApplicationHostOptions { OpcUaPort = 0 });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain("OpcUa:OpcUaPort must be between 1 and 65535 (was 0)");
|
|
}
|
|
|
|
/// <summary>A blank public hostname reports the shared required failure with the OpcUa prefix.</summary>
|
|
[Fact]
|
|
public void Blank_public_hostname_fails()
|
|
{
|
|
var result = Sut.Validate(null, new OpcUaApplicationHostOptions { PublicHostname = "" });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain("OpcUa:PublicHostname is required");
|
|
}
|
|
|
|
/// <summary>An empty security-profile list reports the shared min-count failure with the OpcUa prefix.</summary>
|
|
[Fact]
|
|
public void Empty_security_profiles_fails()
|
|
{
|
|
var result = Sut.Validate(null, new OpcUaApplicationHostOptions
|
|
{
|
|
EnabledSecurityProfiles = new List<OpcUaSecurityProfile>(),
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures.ShouldContain("OpcUa:EnabledSecurityProfiles must contain at least 1 item(s) (had 0)");
|
|
}
|
|
|
|
/// <summary>#833 review: an enabled lockout with a non-positive duration fails; a disabled one is not checked.</summary>
|
|
[Theory]
|
|
[InlineData(5, 0, 60, false)]
|
|
[InlineData(5, 300, 0, false)]
|
|
[InlineData(5, -1, -1, false)]
|
|
[InlineData(5, 30, 600, true)]
|
|
[InlineData(0, 0, 0, true)]
|
|
[InlineData(-1, 0, 0, true)]
|
|
public void Credential_lockout_durations_must_be_positive_while_enabled(
|
|
int maxFailedAttempts, int lockoutSeconds, int windowSeconds, bool valid)
|
|
{
|
|
var result = Sut.Validate(null, new OpcUaApplicationHostOptions
|
|
{
|
|
CredentialLockout = new ZB.MOM.WW.OtOpcUa.OpcUaServer.Security.OpcUaCredentialLockoutOptions
|
|
{
|
|
MaxFailedAttempts = maxFailedAttempts, LockoutSeconds = lockoutSeconds, FailureWindowSeconds = windowSeconds,
|
|
},
|
|
});
|
|
|
|
result.Succeeded.ShouldBe(valid);
|
|
if (!valid) result.Failures!.ShouldContain(f => f.Contains("OpcUa:CredentialLockout:LockoutSeconds"));
|
|
}
|
|
|
|
/// <summary>#833 review: the slot wait must be positive while the lockout is enabled.</summary>
|
|
[Fact]
|
|
public void Credential_lockout_slot_wait_must_be_positive_while_enabled()
|
|
{
|
|
var result = Sut.Validate(null, new OpcUaApplicationHostOptions
|
|
{
|
|
CredentialLockout = new ZB.MOM.WW.OtOpcUa.OpcUaServer.Security.OpcUaCredentialLockoutOptions { SlotWaitSeconds = 0 },
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.Failures!.ShouldContain(f => f.Contains("OpcUa:CredentialLockout:SlotWaitSeconds"));
|
|
}
|
|
|
|
/// <summary>#844: the per-client cap may be unset (0) or at least the per-credential threshold.</summary>
|
|
[Theory]
|
|
[InlineData(0, true)]
|
|
[InlineData(5, true)]
|
|
[InlineData(20, true)]
|
|
[InlineData(4, false)]
|
|
public void Credential_lockout_per_client_cap_is_unset_or_at_least_the_per_credential_threshold(int perClient, bool valid)
|
|
{
|
|
var result = Sut.Validate(null, new OpcUaApplicationHostOptions
|
|
{
|
|
CredentialLockout = new ZB.MOM.WW.OtOpcUa.OpcUaServer.Security.OpcUaCredentialLockoutOptions
|
|
{
|
|
MaxFailedAttempts = 5, MaxFailedAttemptsPerClient = perClient,
|
|
},
|
|
});
|
|
|
|
result.Succeeded.ShouldBe(valid);
|
|
if (!valid) result.Failures!.ShouldContain(f => f.Contains("OpcUa:CredentialLockout:MaxFailedAttemptsPerClient"));
|
|
}
|
|
|
|
/// <summary>#876: discovery registration is opt-in; a configured URL must be opc.tcp and the interval positive.</summary>
|
|
[Theory]
|
|
[InlineData(null, 30_000, true)]
|
|
[InlineData("", 0, true)]
|
|
[InlineData("opc.tcp://lds-host:4840", 30_000, true)]
|
|
[InlineData("http://lds-host:4840", 30_000, false)]
|
|
[InlineData("lds-host:4840", 30_000, false)]
|
|
[InlineData("opc.tcp://lds-host:4840", 0, false)]
|
|
public void Discovery_server_url_must_be_opc_tcp_with_a_positive_interval(string? url, int intervalMs, bool valid)
|
|
{
|
|
var result = Sut.Validate(null, new OpcUaApplicationHostOptions
|
|
{
|
|
DiscoveryServerUrl = url, DiscoveryRegistrationIntervalMs = intervalMs,
|
|
});
|
|
|
|
result.Succeeded.ShouldBe(valid);
|
|
if (!valid) result.Failures!.ShouldContain(f => f.Contains("OpcUa:Discovery"));
|
|
}
|
|
}
|