namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
///
/// 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.
///
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(() => 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(() => 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(() => options.Validate());
Assert.Contains("Secrets:GrpcHub:BearerToken", ex.Message, StringComparison.Ordinal);
}
public static TheoryData> 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 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(
() => options.Validate("Central:Secrets:Hub"));
Assert.Contains("Central:Secrets:Hub:BearerToken", ex.Message, StringComparison.Ordinal);
}
}