5439f14804
Phase 2 Task 0. Binds a validated `MeshTransport` section selecting which transport carries central->node commands, defaulting to Dps so nothing changes until the rig gate passes. The validator exists because every fault it catches otherwise surfaces as an ABSENCE -- a deployment that never arrives, an alarm ack that does nothing -- which has no stack trace and no failing node to point at. Two of the shapes it rejects are ported from the sister project's shipped mistakes: a contact point carrying an actor-path suffix, and (documented in the options XML) a template listing the node's OWN remoting port as a central contact, which is a permanent failure in the initial-contact rotation. Contact points are required only under ClusterClient mode; requiring them under the default would make the section mandatory on admin-only nodes that have no reason to carry them. Sabotage-verified: relaxing the empty-contacts guard and the actor-path-suffix guard turns exactly those two tests red, and nothing else. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
139 lines
4.6 KiB
C#
139 lines
4.6 KiB
C#
using Microsoft.Extensions.Options;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
|
|
|
/// <summary>
|
|
/// Every failure this validator catches otherwise surfaces as an <i>absence</i> — a deployment
|
|
/// that never arrives, an alarm ack that does nothing, a node whose ack is dropped without a
|
|
/// trace. Absences are the hardest class of fault to attribute in the field, so the whole point
|
|
/// of these tests is that the host refuses to start instead.
|
|
/// </summary>
|
|
public class MeshTransportOptionsValidatorTests
|
|
{
|
|
private static ValidateOptionsResult Validate(MeshTransportOptions o) =>
|
|
new MeshTransportOptionsValidator().Validate(MeshTransportOptions.SectionName, o);
|
|
|
|
[Fact]
|
|
public void Default_options_are_valid()
|
|
{
|
|
// The default is Dps — the pre-Phase-2 transport. A node that never sets this section must
|
|
// behave exactly as it did before, which is the whole premise of the dark switch.
|
|
Validate(new MeshTransportOptions()).Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Unknown_mode_fails()
|
|
{
|
|
var result = Validate(new MeshTransportOptions { Mode = "grpc" });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("grpc");
|
|
}
|
|
|
|
[Fact]
|
|
public void Mode_matching_is_case_insensitive()
|
|
{
|
|
Validate(new MeshTransportOptions
|
|
{
|
|
Mode = "clusterclient",
|
|
CentralContactPoints = ["akka.tcp://otopcua@central-1:4053"],
|
|
}).Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Non_positive_contact_refresh_fails()
|
|
{
|
|
var result = Validate(new MeshTransportOptions { ContactRefreshSeconds = 0 });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain(nameof(MeshTransportOptions.ContactRefreshSeconds));
|
|
}
|
|
|
|
[Fact]
|
|
public void ClusterClient_mode_requires_central_contact_points()
|
|
{
|
|
var result = Validate(new MeshTransportOptions
|
|
{
|
|
Mode = MeshTransportOptions.ModeClusterClient,
|
|
CentralContactPoints = [],
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain(nameof(MeshTransportOptions.CentralContactPoints));
|
|
}
|
|
|
|
[Fact]
|
|
public void Dps_mode_does_not_require_central_contact_points()
|
|
{
|
|
// An admin-only node has no reason to carry them, and requiring them under the default mode
|
|
// would make the section mandatory everywhere for a feature that is switched off.
|
|
Validate(new MeshTransportOptions
|
|
{
|
|
Mode = MeshTransportOptions.ModeDps,
|
|
CentralContactPoints = [],
|
|
}).Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_contact_point_that_is_not_an_akka_address_fails()
|
|
{
|
|
var result = Validate(new MeshTransportOptions
|
|
{
|
|
Mode = MeshTransportOptions.ModeClusterClient,
|
|
CentralContactPoints = ["central-1:4053"],
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("akka.tcp://");
|
|
}
|
|
|
|
[Fact]
|
|
public void A_contact_point_carrying_an_actor_path_suffix_fails()
|
|
{
|
|
// The sister project's shipped site template listed a full receptionist path, and separately
|
|
// listed the site's OWN remoting port as the second contact — "a permanent failure in the
|
|
// initial-contact rotation". Both are invisible until a command goes missing.
|
|
var result = Validate(new MeshTransportOptions
|
|
{
|
|
Mode = MeshTransportOptions.ModeClusterClient,
|
|
CentralContactPoints = ["akka.tcp://otopcua@central-1:4053/system/receptionist"],
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("/system/receptionist");
|
|
}
|
|
|
|
[Fact]
|
|
public void One_bad_contact_point_among_good_ones_still_fails()
|
|
{
|
|
var result = Validate(new MeshTransportOptions
|
|
{
|
|
Mode = MeshTransportOptions.ModeClusterClient,
|
|
CentralContactPoints =
|
|
[
|
|
"akka.tcp://otopcua@central-1:4053",
|
|
"central-2:4053",
|
|
],
|
|
});
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("central-2:4053");
|
|
}
|
|
|
|
[Fact]
|
|
public void Well_formed_ClusterClient_options_succeed()
|
|
{
|
|
Validate(new MeshTransportOptions
|
|
{
|
|
Mode = MeshTransportOptions.ModeClusterClient,
|
|
CentralContactPoints =
|
|
[
|
|
"akka.tcp://otopcua@central-1:4053",
|
|
"akka.tcp://otopcua@central-2:4053",
|
|
],
|
|
}).Succeeded.ShouldBeTrue();
|
|
}
|
|
}
|