0d4978953f
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
193 lines
7.0 KiB
C#
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);
|
|
}
|
|
}
|