Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsGrpcHubClientOptionsTests.cs
T
Joseph Doherty 0d4978953f feat(secrets-grpc): FallbackEndpoints + sticky failover hub reader (scadaproj#4)
SecretsGrpcHubClientOptions gains a getter-only FallbackEndpoints list:
additional hub addresses tried in order when a call fails with a
transport-level RpcException. Validation fails startup on blank/relative
entries and on any duplicate of the primary or another fallback (a
duplicated endpoint silently halves the configured failover).

New FailoverSecretsHubReader composes one GrpcSecretsHubClient per
endpoint: the answering endpoint is sticky (a sweep is GetManifest + N
GetSecrets batches — without stickiness every call after a primary
outage re-pays a full CallDeadline before reaching the healthy
fallback), rotation wraps so a recovered primary is retried when the
stuck-to endpoint fails, only RpcException triggers failover
(cancellation and defects propagate untouched), and when every endpoint
fails the last RpcException is rethrown so the sweep degrades to
last-known-good exactly as today. Failover warnings name only the two
endpoint URIs and the status code — never any credential material.

DI: zero fallbacks keeps the registration graph byte-identical to 0.4.x
(single keyed channel under ChannelServiceKey, plain
GrpcSecretsHubClient); with fallbacks, one container-owned keyed channel
per endpoint (fallback i under "{ChannelServiceKey}:fallback:{i}") wired
into the failover reader. GrpcSecretsHubClient itself is unchanged.

Safe only when every listed endpoint serves the SAME store — for
ScadaBridge, the central pair over one shared SQL Server store; the
options doc and CHANGELOG carry the warning.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 09:37:22 -04:00

193 lines
7.0 KiB
C#

namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
/// <summary>
/// Fail-closed validation of the follower's hub-client options. Every message must name the exact
/// configuration key an operator has to fix, and none of them may ever echo the bearer token.
/// </summary>
public sealed class SecretsGrpcHubClientOptionsTests
{
private const string Token = "s3cr3t-hub-token";
private static SecretsGrpcHubClientOptions Valid() => new()
{
Endpoint = "https://central:5001",
BearerToken = Token,
};
[Fact]
public void Defaults_match_the_documented_contract()
{
var options = new SecretsGrpcHubClientOptions();
Assert.Equal(TimeSpan.FromSeconds(30), options.SyncInterval);
Assert.Equal(TimeSpan.FromSeconds(30), options.CallDeadline);
Assert.True(options.SyncOnStartup);
}
[Fact]
public void A_complete_configuration_validates()
{
Valid().Validate();
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void A_missing_endpoint_names_its_key(string endpoint)
{
SecretsGrpcHubClientOptions options = Valid();
options.Endpoint = endpoint;
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Secrets:GrpcHub:Endpoint", ex.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData("central:5001")]
[InlineData("/relative/path")]
[InlineData("ftp://central:5001")]
[InlineData("unix:///var/run/hub.sock")]
public void An_endpoint_that_is_not_an_absolute_http_uri_names_its_key(string endpoint)
{
SecretsGrpcHubClientOptions options = Valid();
options.Endpoint = endpoint;
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Secrets:GrpcHub:Endpoint", ex.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void A_missing_bearer_token_names_its_key(string token)
{
SecretsGrpcHubClientOptions options = Valid();
options.BearerToken = token;
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Secrets:GrpcHub:BearerToken", ex.Message, StringComparison.Ordinal);
}
public static TheoryData<Action<SecretsGrpcHubClientOptions>> InvalidMutations() =>
[
o => o.Endpoint = string.Empty,
o => o.Endpoint = "not a uri",
o => o.BearerToken = string.Empty,
o => o.SyncInterval = TimeSpan.Zero,
o => o.CallDeadline = TimeSpan.FromSeconds(-1),
o => o.FallbackEndpoints.Add("not a uri"),
o => o.FallbackEndpoints.Add("https://central:5001"),
];
[Theory]
[MemberData(nameof(InvalidMutations))]
public void No_validation_message_ever_echoes_the_bearer_token(
Action<SecretsGrpcHubClientOptions> makeInvalid)
{
// A configuration error surfaces in logs and crash dumps. Naming the key is the whole job;
// quoting the value would leak the hub credential to everyone who can read a startup log.
SecretsGrpcHubClientOptions options = Valid();
makeInvalid(options);
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.DoesNotContain(Token, ex.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void A_non_positive_sync_interval_names_its_key(int seconds)
{
SecretsGrpcHubClientOptions options = Valid();
options.SyncInterval = TimeSpan.FromSeconds(seconds);
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Secrets:GrpcHub:SyncInterval", ex.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void A_non_positive_call_deadline_names_its_key(int seconds)
{
SecretsGrpcHubClientOptions options = Valid();
options.CallDeadline = TimeSpan.FromSeconds(seconds);
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Secrets:GrpcHub:CallDeadline", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Zero_fallback_endpoints_is_valid_and_the_default()
{
SecretsGrpcHubClientOptions options = Valid();
Assert.Empty(options.FallbackEndpoints);
options.Validate();
}
[Fact]
public void A_well_formed_fallback_endpoint_validates()
{
SecretsGrpcHubClientOptions options = Valid();
options.FallbackEndpoints.Add("https://central-b:5001");
options.FallbackEndpoints.Add("http://central-c:5001");
options.Validate();
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("central-b:5001")]
[InlineData("/relative/path")]
[InlineData("ftp://central-b:5001")]
public void A_fallback_that_is_not_an_absolute_http_uri_names_its_key(string fallback)
{
SecretsGrpcHubClientOptions options = Valid();
options.FallbackEndpoints.Add(fallback);
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Secrets:GrpcHub:FallbackEndpoints", ex.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData("https://central:5001")]
[InlineData("HTTPS://CENTRAL:5001")]
public void A_fallback_duplicating_the_primary_endpoint_names_its_key(string fallback)
{
// A duplicated endpoint silently halves the failover the operator thinks they configured.
SecretsGrpcHubClientOptions options = Valid();
options.FallbackEndpoints.Add(fallback);
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Secrets:GrpcHub:FallbackEndpoints", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void A_fallback_duplicating_another_fallback_names_its_key()
{
SecretsGrpcHubClientOptions options = Valid();
options.FallbackEndpoints.Add("https://central-b:5001");
options.FallbackEndpoints.Add("HTTPS://central-B:5001");
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Secrets:GrpcHub:FallbackEndpoints", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Messages_name_the_section_the_caller_actually_bound()
{
// The registration extension takes a section path, so a deployment that binds somewhere else
// must be told about ITS key, not the default one.
SecretsGrpcHubClientOptions options = Valid();
options.BearerToken = string.Empty;
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
() => options.Validate("Central:Secrets:Hub"));
Assert.Contains("Central:Secrets:Hub:BearerToken", ex.Message, StringComparison.Ordinal);
}
}