4d2dfb5b50
MeshGrpcBoundaryTests replaces the MeshClusterClientBoundaryTests deleted in step 1. Three
self-seeded single-member clusters sharing the ActorSystem name, joined ONLY by gRPC, driving
the REAL CentralCommunicationActor -> MeshGrpcCommandDialer -> Kestrel h2c ->
MeshCommandAuthInterceptor -> NodeCommandGrpcService -> NodeCommunicationActor -> EventStream:
1. No_cross_mesh_Akka_association_forms_between_central_and_either_site — the program's own
point, kept as a test rather than a live-gate note: each cluster stays single-member and
none ever holds another's address as a member.
2. One_dispatch_through_central_reaches_a_node_in_every_cluster_mesh — the fan, with each
site holding its OWN per-cluster PSK, so a dial that selected the wrong cluster's key
would be refused at the far side.
3. Falsifiability_control_a_dial_carrying_the_wrong_cluster_key_delivers_nothing — without
it, (2) cannot distinguish "the gRPC boundary carried it" from "something else in the
process did". The same node then accepts the right key, so the silence is the auth gate
and not a dead endpoint.
All three run fully offline — no Docker fixture — and pass.
Comment sweep across every remaining clusterclient|receptionist hit in src/ and tests/:
the program label becomes "mesh gRPC command plane", and every comment that described the
deleted transport as live now describes what actually runs (Dps = pair-local default,
Grpc = cross-mesh). mesh_transport.proto's header/service comments are reworded; no message,
field or enum is touched — the contract is locked. MeshCommActorPathTests is reshaped rather
than retired: the two paths still exist and still matter, but what a mismatch costs is now a
misleading Unavailable from the gRPC ingress rather than a silently dropped receptionist send.
The only surviving mentions of the old names are the ones that must survive: the fail-closed
rules that name "ClusterClient" as a retired value refused at boot, their tests, and this
test class's own account of what it replaces.
409 lines
17 KiB
C#
409 lines
17 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
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>
|
|
/// <remarks>
|
|
/// Role-dependent facts run in <see cref="RoleEnvironmentCollection"/> because the validator
|
|
/// resolves roles through <c>RoleParser.ResolveEffectiveRoles</c>, which reads
|
|
/// <c>OTOPCUA_ROLES</c> when <c>Cluster:Roles</c> is empty — a process-wide env var, so the
|
|
/// whole class is serialized against the other role-reading suites.
|
|
/// </remarks>
|
|
[Collection(RoleEnvironmentCollection.Name)]
|
|
public class MeshTransportOptionsValidatorTests
|
|
{
|
|
private static ValidateOptionsResult Validate(MeshTransportOptions o, params string[] roles) =>
|
|
Validate(o, new Dictionary<string, string?>(), roles);
|
|
|
|
/// <summary>
|
|
/// As <see cref="Validate(MeshTransportOptions, string[])"/>, plus arbitrary configuration
|
|
/// keys — the admin-side rule cross-reads <c>ConfigServe:GrpcListenPort</c>, which lives
|
|
/// outside this options class entirely.
|
|
/// </summary>
|
|
private static ValidateOptionsResult Validate(
|
|
MeshTransportOptions o, Dictionary<string, string?> extra, params string[] roles)
|
|
{
|
|
var pairs = new Dictionary<string, string?>(extra);
|
|
for (var i = 0; i < roles.Length; i++)
|
|
{
|
|
pairs[$"Cluster:Roles:{i}"] = roles[i];
|
|
}
|
|
|
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build();
|
|
|
|
return new MeshTransportOptionsValidator(configuration)
|
|
.Validate(MeshTransportOptions.SectionName, o);
|
|
}
|
|
|
|
private static Dictionary<string, string?> ConfigServePort(string? value) =>
|
|
new() { ["ConfigServe:GrpcListenPort"] = value };
|
|
|
|
private static MeshTransportOptions WellFormedGrpc() => new()
|
|
{
|
|
Mode = MeshTransportOptions.ModeGrpc,
|
|
GrpcPsk = "not-a-real-key",
|
|
CentralGrpcEndpoints = ["http://central-1:4055", "https://central-2:4055"],
|
|
};
|
|
|
|
[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 = "not-a-transport" });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("not-a-transport");
|
|
}
|
|
|
|
[Fact]
|
|
public void Unknown_mode_message_names_both_accepted_values()
|
|
{
|
|
// An operator who mistypes the mode gets the full menu, not a partial one.
|
|
var result = Validate(new MeshTransportOptions { Mode = "not-a-mode" });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeDps);
|
|
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeGrpc);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_retired_ClusterClient_mode_fails_rather_than_falling_back_to_Dps()
|
|
{
|
|
// A node left carrying the deleted transport's mode value must be refused at boot. Silently
|
|
// treating it as Dps would leave a split-topology node running, listening, and delivering
|
|
// nothing across its mesh boundary — the exact absence this validator exists to prevent.
|
|
var result = Validate(new MeshTransportOptions { Mode = "ClusterClient" });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("MeshTransport:Mode");
|
|
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeGrpc);
|
|
}
|
|
|
|
[Fact]
|
|
public void Mode_matching_is_case_insensitive()
|
|
{
|
|
Validate(new MeshTransportOptions { Mode = "dps" }).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));
|
|
}
|
|
|
|
// ---- Grpc mode (the cross-mesh command plane) -----------------------------------------------
|
|
|
|
[Fact]
|
|
public void Grpc_is_an_accepted_mode()
|
|
{
|
|
Validate(WellFormedGrpc(), "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_mode_matching_is_case_insensitive()
|
|
{
|
|
var options = WellFormedGrpc();
|
|
options.Mode = "grpc";
|
|
|
|
Validate(options, "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_driver_node_without_a_psk_fails()
|
|
{
|
|
var options = WellFormedGrpc();
|
|
options.GrpcPsk = null;
|
|
|
|
var result = Validate(options, "driver", "cluster-SITE-A");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("MeshTransport:GrpcPsk");
|
|
result.FailureMessage.ShouldContain("MeshTransport__GrpcPsk");
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_driver_node_with_an_empty_psk_fails()
|
|
{
|
|
// Empty is the shape an unset environment variable actually produces, and it is exactly as
|
|
// fatal as null — a blank bearer authenticates nothing.
|
|
var options = WellFormedGrpc();
|
|
options.GrpcPsk = " ";
|
|
|
|
Validate(options, "driver", "cluster-SITE-A").Failed.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void The_psk_value_is_never_echoed_in_a_failure_message()
|
|
{
|
|
// The whole point of a validator that reads a secret is that its diagnostics are safe to
|
|
// paste into a ticket. Naming the key is the contract; printing it is a leak.
|
|
var options = WellFormedGrpc();
|
|
options.GrpcPsk = "super-secret-mesh-psk";
|
|
options.CentralGrpcEndpoints = ["central-1:4055"];
|
|
|
|
var result = Validate(options, "driver", "cluster-SITE-A");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldNotContain("super-secret-mesh-psk");
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_driver_node_without_central_endpoints_fails()
|
|
{
|
|
var options = WellFormedGrpc();
|
|
options.CentralGrpcEndpoints = [];
|
|
|
|
var result = Validate(options, "driver", "cluster-SITE-A");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("MeshTransport:CentralGrpcEndpoints");
|
|
}
|
|
|
|
[Fact]
|
|
public void A_central_endpoint_that_is_not_an_http_url_fails()
|
|
{
|
|
var options = WellFormedGrpc();
|
|
options.CentralGrpcEndpoints = ["central-1:4055"];
|
|
|
|
var result = Validate(options, "driver", "cluster-SITE-A");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("central-1:4055");
|
|
result.FailureMessage.ShouldContain("http://");
|
|
}
|
|
|
|
[Fact]
|
|
public void One_bad_central_endpoint_among_good_ones_still_fails()
|
|
{
|
|
var options = WellFormedGrpc();
|
|
options.CentralGrpcEndpoints = ["http://central-1:4055", "akka.tcp://otopcua@central-2:4053"];
|
|
|
|
var result = Validate(options, "driver", "cluster-SITE-A");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("akka.tcp://otopcua@central-2:4053");
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_driver_node_with_a_non_positive_dispatch_timeout_fails()
|
|
{
|
|
var options = WellFormedGrpc();
|
|
options.DispatchTimeoutSeconds = 0;
|
|
|
|
var result = Validate(options, "driver", "cluster-SITE-A");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("MeshTransport:DispatchTimeoutSeconds");
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_driver_node_with_a_non_positive_ack_timeout_fails()
|
|
{
|
|
var options = WellFormedGrpc();
|
|
options.AckTimeoutSeconds = -1;
|
|
|
|
var result = Validate(options, "driver", "cluster-SITE-A");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("MeshTransport:AckTimeoutSeconds");
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_admin_only_node_needs_none_of_the_node_side_keys()
|
|
{
|
|
// The node-side surface is what a DRIVER uses to dial central and post its ack. An admin-only
|
|
// central node dials nodes instead and resolves per-cluster keys at runtime, so requiring
|
|
// these here would make an admin node carry a PSK and an endpoint list it never reads.
|
|
// (The ConfigServe port IS required of an admin node under Grpc — supplied here so this test
|
|
// stays about the node-side keys and nothing else.)
|
|
Validate(
|
|
new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc },
|
|
ConfigServePort("4055"),
|
|
"admin", "cluster-MAIN").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_admin_node_without_a_config_serve_listener_fails()
|
|
{
|
|
// Central hosts CentralCommandService on the ConfigServe h2c listener, so an unset port means
|
|
// the service is never mapped and every node's ack has nowhere to land. Nothing errors at
|
|
// runtime — the deployment simply times out naming nodes that applied perfectly, which is the
|
|
// exact absence this validator exists to convert into a refusal to start.
|
|
var result = Validate(
|
|
new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc },
|
|
"admin", "cluster-MAIN");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("ConfigServe:GrpcListenPort");
|
|
result.FailureMessage.ShouldContain("ConfigServe__GrpcListenPort");
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_admin_node_with_a_zero_config_serve_port_fails()
|
|
{
|
|
// 0 is the documented "disabled" value, not merely a missing key — it must fail exactly as an
|
|
// absent one does, because it binds nothing just as surely.
|
|
Validate(
|
|
new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc },
|
|
ConfigServePort("0"),
|
|
"admin", "cluster-MAIN").Failed.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_admin_node_with_an_unparseable_config_serve_port_fails()
|
|
{
|
|
// A typo'd port binds nothing, so it belongs on the same side of the line as an absent one.
|
|
Validate(
|
|
new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc },
|
|
ConfigServePort("40o55"),
|
|
"admin", "cluster-MAIN").Failed.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_admin_node_with_a_config_serve_listener_passes()
|
|
{
|
|
Validate(
|
|
new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc },
|
|
ConfigServePort("4055"),
|
|
"admin", "cluster-MAIN").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_driver_only_node_needs_no_config_serve_listener()
|
|
{
|
|
// A driver node dials central; it serves no central command endpoint at all, and requiring an
|
|
// admin-side listener port of it would demand configuration it never reads.
|
|
Validate(WellFormedGrpc(), "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Dps_admin_node_needs_no_config_serve_listener()
|
|
{
|
|
// The no-new-obligations invariant, admin side: a central node still on the default transport
|
|
// publishes on DistributedPubSub and hosts no command endpoint.
|
|
Validate(
|
|
new MeshTransportOptions { Mode = MeshTransportOptions.ModeDps },
|
|
"admin", "cluster-MAIN").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void The_admin_role_is_also_read_from_OTOPCUA_ROLES()
|
|
{
|
|
// Same roles-source divergence as the driver case (Gitea #532), on the side that hosts the
|
|
// endpoint: a central node configured only through the env var is still the node whose
|
|
// listener every ack depends on.
|
|
using (new EnvVarScope("OTOPCUA_ROLES", "admin,cluster-MAIN"))
|
|
{
|
|
Validate(new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc })
|
|
.Failed.ShouldBeTrue();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_role_less_node_needs_none_of_the_node_side_keys()
|
|
{
|
|
// Every in-process test host and every single-mesh node has no declared role. Requiring the
|
|
// Grpc surface of them would turn a dark-switch value into a tree-wide config obligation.
|
|
Validate(new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }).Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Roles_come_from_OTOPCUA_ROLES_when_Cluster_Roles_is_empty()
|
|
{
|
|
// Roles-source divergence (Gitea #532): a node configured only through the env var is still a
|
|
// driver node in Akka. A validator reading Cluster:Roles alone would exempt the exact node
|
|
// that has to dial central, and its acks would be rejected unauthenticated at runtime.
|
|
using (new EnvVarScope("OTOPCUA_ROLES", "driver,cluster-SITE-A"))
|
|
{
|
|
var options = WellFormedGrpc();
|
|
options.GrpcPsk = null;
|
|
|
|
Validate(options).Failed.ShouldBeTrue();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Dps_mode_requires_none_of_the_new_Grpc_keys()
|
|
{
|
|
// The no-new-obligations invariant: adding a third mode must not make any existing
|
|
// deployment's configuration invalid. A Dps driver node that sets nothing new still passes.
|
|
Validate(
|
|
new MeshTransportOptions { Mode = MeshTransportOptions.ModeDps },
|
|
"driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_fused_admin_and_driver_node_must_satisfy_BOTH_surfaces()
|
|
{
|
|
// The central pair is fused (admin,driver,cluster-MAIN), so it is the one node shape that owes
|
|
// both halves at once — and the two blocks are independent `if`s, not an `if`/`else if`. Were
|
|
// they chained, a fused node would be graded on the driver surface only and its missing
|
|
// ConfigServe listener would pass validation, leaving central with no place for any node's ack
|
|
// to land. Asserted from BOTH sides so a future chaining in either order is caught.
|
|
var missingNodeSide = new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc };
|
|
var withPort = Validate(missingNodeSide, ConfigServePort("4055"), "driver", "admin", "cluster-MAIN");
|
|
withPort.Failed.ShouldBeTrue();
|
|
withPort.FailureMessage.ShouldContain("MeshTransport:GrpcPsk");
|
|
withPort.FailureMessage.ShouldContain("MeshTransport:CentralGrpcEndpoints");
|
|
|
|
var missingAdminSide = Validate(WellFormedGrpc(), "driver", "admin", "cluster-MAIN");
|
|
missingAdminSide.Failed.ShouldBeTrue();
|
|
missingAdminSide.FailureMessage.ShouldContain("ConfigServe:GrpcListenPort");
|
|
|
|
// And the fully-configured fused node passes — the rule is "both", not "neither".
|
|
Validate(WellFormedGrpc(), ConfigServePort("4055"), "driver", "admin", "cluster-MAIN")
|
|
.Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void ClusterPsks_defaults_to_an_empty_case_insensitive_map()
|
|
{
|
|
// Production leaves the map empty and the runtime provider falls back to the Secrets store;
|
|
// the rig populates it. Case-insensitivity matters because a ClusterId reaches this lookup
|
|
// from three sources — a role suffix, a DB column and an env key — that do not agree on case.
|
|
var options = new MeshTransportOptions();
|
|
|
|
options.ClusterPsks.ShouldBeEmpty();
|
|
options.ClusterPsks["MAIN"] = "rig-key";
|
|
options.ClusterPsks.ContainsKey("main").ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void ClusterPsks_stays_case_insensitive_after_binding_configuration()
|
|
{
|
|
// Asserting the comparer on a fresh instance proves nothing about the shape that reaches
|
|
// production: the options binder could construct its own Dictionary and drop the comparer
|
|
// silently, and the symptom would be a rig lookup that misses on a ClusterId spelled the way
|
|
// the DB spells it. Bind for real and check the comparer survived.
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["MeshTransport:ClusterPsks:SITE-A"] = "rig-key",
|
|
})
|
|
.Build();
|
|
|
|
var options = new MeshTransportOptions();
|
|
configuration.GetSection(MeshTransportOptions.SectionName).Bind(options);
|
|
|
|
options.ClusterPsks["site-a"].ShouldBe("rig-key");
|
|
}
|
|
}
|