using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.OtOpcUa.OpcUaServer; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// /// Task 4 — verifies the net-new (built on the /// shared ZB.MOM.WW.Configuration OptionsValidatorBase/ValidationBuilder) that /// gates the OPC UA host options at startup. The C# defaults are all valid so a host with no /// explicit "OpcUa" section still passes; the validator exists to reject explicit /// prod/env overrides. Failure messages carry the real "OpcUa:" section prefix and the /// exact shared-primitive wording so they read correctly when surfaced via ValidateOnStart. /// public sealed class OpcUaApplicationHostOptionsValidatorTests { private static readonly OpcUaApplicationHostOptionsValidator Sut = new(); /// The C# defaults (the as-bound shape when the section is absent) pass validation. [Fact] public void Default_options_succeed() { Sut.Validate(null, new OpcUaApplicationHostOptions()).Succeeded.ShouldBeTrue(); } /// A port of 0 reports the shared port-range failure with the OpcUa prefix. [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)"); } /// A blank public hostname reports the shared required failure with the OpcUa prefix. [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"); } /// An empty security-profile list reports the shared min-count failure with the OpcUa prefix. [Fact] public void Empty_security_profiles_fails() { var result = Sut.Validate(null, new OpcUaApplicationHostOptions { EnabledSecurityProfiles = new List(), }); result.Failed.ShouldBeTrue(); result.Failures.ShouldContain("OpcUa:EnabledSecurityProfiles must contain at least 1 item(s) (had 0)"); } /// #833 review: an enabled lockout with a non-positive duration fails; a disabled one is not checked. [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")); } /// #833 review: the slot wait must be positive while the lockout is enabled. [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")); } /// #844: the per-client cap may be unset (0) or at least the per-credential threshold. [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")); } /// #876: discovery registration is opt-in; a configured URL must be opc.tcp and the interval positive. [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")); } }