Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/OpcUaApplicationHostOptionsValidatorTests.cs
T
Joseph Doherty 988927c8ac fix(opcua): credential lockout review fixes (#833)
- Reserve an in-flight slot per client before authenticating (TryBegin /
  Complete / Abandon under one lock); refuse once recorded failures +
  in-flight attempts reach MaxFailedAttempts, so a parallel burst cannot
  pass the check before its first failure is recorded.
- An expired lockout resets the count (LockoutSeconds < FailureWindowSeconds
  relocked on every single failure after the first lockout).
- Validate OpcUa:CredentialLockout at host start: disabled, or both
  durations > 0.
- Log the first refusal of a lockout episode at Information, the rest at
  Debug.
- Document that on None endpoints the key is effectively IP-only (NAT
  neighbours share a UserName count) and a null peer shares one key.
2026-11-11 02:32:40 -05:00

83 lines
3.4 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"));
}
}