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); } }