Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/OpcUaApplicationHostOptionsValidatorTests.cs
T
Joseph Doherty 7829b937d1 feat(717): Warm non-transparent redundancy, default-off (T1-T10)
OpcUa:Redundancy:Mode (Unset default = today's server exactly; None; Warm; Hot and unknown values fail start).
- T1 options + validator + effective-URI check + boot banner; Warm needs the driver role.
- T2 metadata in OnNodeManagerStartedAsync: Warm -> NonTransparentRedundancyType, read-only
  ServerUriArray [self, peers], RedundancySupport=Warm, boot ServiceLevel 1; None -> RedundancySupport=None
  and the SDK's orphan i=11314 deleted; Unset untouched.
- T3 RedundancyStateChanged.Revision (up-number, incarnation tick, sequence); subscribers drop older
  revisions under Warm (OpcUaPublishActor, DriverHostActor); unrevisioned never makes a node Active.
- T4 WarmRoleAuthority + ComputeWarm bands (Standby 90/50, Active 250/200/100), PromoteWhilePrimaryUnreachable.
- T5 IStandbyProjection + DeferredStandbyProjection; OpcUaPublishActor runs promotion/demotion in the spec
  4.7 order (Active level last and retried; Standby level first, data degraded regardless).
- T6 EquipmentVariableState read projection (BadOutOfService/null); Warm Standby refuses writes BadInvalidState.
- T7 D4 mechanism adopted after T0: ordinary-tag marker on MonitoredItemNotification.Message validated by
  (ClientHandle, DataValue); first-delivery decision keyed by (SubscriptionId, SequenceNumber); the SDK's
  cached copy is never mutated.
- T8 queue current/BadOutOfService to live ordinary-tag items ignoring filters.
- T9 alarm stamp/deliver split (suppressed on Standby), Part 9 methods refused on Standby, promotion
  refresh via ISubscription.ConditionRefresh2Async on every live event item.
- T10 deploy-time RedundancyMode cross-check + six instruments.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 03:39:50 -05:00

170 lines
7.2 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"));
}
}
/// <summary>Gitea #717 spec §3: the redundancy rules the startup validator and the driver-role guard enforce.</summary>
public sealed class OpcUaRedundancyValidationTests
{
private static readonly OpcUaApplicationHostOptionsValidator Sut = new();
[Theory]
[InlineData("Unset", new string[0], true)]
[InlineData("None", new string[0], true)]
[InlineData("None", new[] { "urn:peer" }, false)]
[InlineData("Warm", new[] { "urn:peer" }, true)]
[InlineData("Warm", new string[0], false)]
[InlineData("Warm", new[] { "urn:peer", "urn:peer" }, false)]
[InlineData("Warm", new[] { "urn:OtOpcUa" }, false)]
[InlineData("Hot", new[] { "urn:peer" }, false)]
[InlineData("Bogus", new string[0], false)]
public void Redundancy_matrix(string mode, string[] peers, bool valid)
{
var result = Sut.Validate(null, new OpcUaApplicationHostOptions
{
Redundancy = new OpcUaRedundancyOptions { Mode = mode },
PeerApplicationUris = peers.ToList(),
});
result.Succeeded.ShouldBe(valid, result.FailureMessage);
}
[Fact]
public void Warm_on_a_node_without_the_driver_role_fails_start()
{
Should.Throw<InvalidOperationException>(() => OpcUaRedundancyStartupGuard.RequireDriverRoleForWarm("Warm", hasDriver: false));
OpcUaRedundancyStartupGuard.RequireDriverRoleForWarm("Warm", hasDriver: true);
OpcUaRedundancyStartupGuard.RequireDriverRoleForWarm(null, hasDriver: false);
OpcUaRedundancyStartupGuard.RequireDriverRoleForWarm("None", hasDriver: false);
}
}