using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection;
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
///
/// Registration-time validation for the hub server. A misconfigured hub must fail at startup with
/// a message naming the exact configuration key, not serve unauthenticated at runtime.
///
public sealed class SecretsGrpcHubServerOptionsTests
{
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t\n")]
public void Validate_throws_when_the_bearer_token_is_unset(string token)
{
var options = new SecretsGrpcHubServerOptions { BearerToken = token };
InvalidOperationException error =
Assert.Throws(() => options.Validate());
Assert.Contains("Secrets:GrpcHub:BearerToken", error.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_names_the_section_path_the_hub_was_registered_under()
{
var options = new SecretsGrpcHubServerOptions { BearerToken = string.Empty };
options.SectionPath = "Central:Secrets:Hub";
InvalidOperationException error =
Assert.Throws(() => options.Validate());
Assert.Contains("Central:Secrets:Hub:BearerToken", error.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_accepts_a_configured_token()
{
var options = new SecretsGrpcHubServerOptions { BearerToken = "a-configured-hub-token" };
options.Validate();
}
[Fact]
public void Validate_never_echoes_the_configured_token()
{
// A token that is present but only whitespace-padded is still a real credential; the
// failure message must never carry it.
const string token = " ";
var options = new SecretsGrpcHubServerOptions { BearerToken = token };
InvalidOperationException error =
Assert.Throws(() => options.Validate());
Assert.DoesNotContain($"'{token}'", error.Message, StringComparison.Ordinal);
Assert.DoesNotContain("was ", error.Message, StringComparison.Ordinal);
}
[Fact]
public void Bearer_token_defaults_to_empty_so_an_unconfigured_hub_cannot_serve()
{
var options = new SecretsGrpcHubServerOptions();
Assert.Equal(string.Empty, options.BearerToken);
Assert.Throws(() => options.Validate());
}
[Fact]
public void Section_path_defaults_to_the_documented_convention()
{
var options = new SecretsGrpcHubServerOptions();
Assert.Equal("Secrets:GrpcHub", options.SectionPath);
}
[Fact]
public void Max_names_per_request_defaults_above_the_followers_own_fetch_chunk()
{
// The follower chunks at 500; the cap sits above that so a compliant sweep never meets it,
// and meeting it therefore means something.
var options = new SecretsGrpcHubServerOptions();
Assert.Equal(1000, options.MaxNamesPerRequest);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(int.MinValue)]
public void Validate_rejects_a_nonpositive_request_cap(int cap)
{
var options = new SecretsGrpcHubServerOptions
{
BearerToken = "a-configured-hub-token",
MaxNamesPerRequest = cap,
};
InvalidOperationException error =
Assert.Throws(() => options.Validate());
Assert.Contains("Secrets:GrpcHub:MaxNamesPerRequest", error.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_accepts_a_cap_of_one()
{
var options = new SecretsGrpcHubServerOptions
{
BearerToken = "a-configured-hub-token",
MaxNamesPerRequest = 1,
};
options.Validate();
}
[Fact]
public void Validate_names_the_section_path_on_the_request_cap_too()
{
var options = new SecretsGrpcHubServerOptions
{
BearerToken = "a-configured-hub-token",
MaxNamesPerRequest = 0,
};
options.SectionPath = "Central:Secrets:Hub";
InvalidOperationException error =
Assert.Throws(() => options.Validate());
Assert.Contains("Central:Secrets:Hub:MaxNamesPerRequest", error.Message, StringComparison.Ordinal);
}
}
///
/// The registration extension itself, driven through a real and a
/// real . What is pinned here is the coherence of what a host ends up
/// holding: the token it configured, bound from the section it named, reported under that same
/// section path — and the refusal of a second registration, which cannot deliver that coherence.
///
public sealed class SecretsGrpcHubServerExtensionsTests
{
private const string Token = "a-configured-hub-token";
[Fact]
public void Registration_binds_the_token_and_stamps_the_default_section_path()
{
var services = new ServiceCollection();
services.AddZbSecretsGrpcHub(Configuration(("Secrets:GrpcHub:BearerToken", Token)));
SecretsGrpcHubServerOptions options = Resolve(services);
Assert.Equal(Token, options.BearerToken);
Assert.Equal("Secrets:GrpcHub", options.SectionPath);
Assert.Equal(1000, options.MaxNamesPerRequest);
}
[Fact]
public void Registration_binds_the_request_cap_when_configured()
{
var services = new ServiceCollection();
services.AddZbSecretsGrpcHub(Configuration(
("Secrets:GrpcHub:BearerToken", Token),
("Secrets:GrpcHub:MaxNamesPerRequest", "250")));
Assert.Equal(250, Resolve(services).MaxNamesPerRequest);
}
[Fact]
public void Registration_under_a_custom_section_path_binds_and_stamps_that_path()
{
var services = new ServiceCollection();
services.AddZbSecretsGrpcHub(
Configuration(("Central:Secrets:Hub:BearerToken", Token)),
"Central:Secrets:Hub");
SecretsGrpcHubServerOptions options = Resolve(services);
Assert.Equal(Token, options.BearerToken);
Assert.Equal("Central:Secrets:Hub", options.SectionPath);
}
[Fact]
public void Registration_under_a_custom_section_path_names_that_path_when_the_token_is_missing()
{
// End to end: the operator is told the key THIS host reads, not the library's default.
var services = new ServiceCollection();
InvalidOperationException error = Assert.Throws(
() => services.AddZbSecretsGrpcHub(
Configuration(("Secrets:GrpcHub:BearerToken", Token)), "Central:Secrets:Hub"));
Assert.Contains("Central:Secrets:Hub:BearerToken", error.Message, StringComparison.Ordinal);
}
[Fact]
public void Registration_without_a_token_throws_naming_the_exact_key()
{
var services = new ServiceCollection();
InvalidOperationException error = Assert.Throws(
() => services.AddZbSecretsGrpcHub(Configuration()));
Assert.Contains("Secrets:GrpcHub:BearerToken", error.Message, StringComparison.Ordinal);
}
[Fact]
public void Registration_with_a_nonpositive_request_cap_throws()
{
var services = new ServiceCollection();
InvalidOperationException error = Assert.Throws(
() => services.AddZbSecretsGrpcHub(Configuration(
("Secrets:GrpcHub:BearerToken", Token),
("Secrets:GrpcHub:MaxNamesPerRequest", "0"))));
Assert.Contains("Secrets:GrpcHub:MaxNamesPerRequest", error.Message, StringComparison.Ordinal);
}
[Fact]
public void Registering_the_hub_twice_throws()
{
var services = new ServiceCollection();
IConfiguration configuration = Configuration(("Secrets:GrpcHub:BearerToken", Token));
services.AddZbSecretsGrpcHub(configuration);
InvalidOperationException error = Assert.Throws(
() => services.AddZbSecretsGrpcHub(configuration));
Assert.Contains("once", error.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Registering_the_hub_twice_under_different_sections_throws_rather_than_merging()
{
// This is the case the guard exists for: binding MERGES (the second section's absent
// BearerToken leaves the first one's standing) while the SectionPath stamp is last-one-wins,
// so the surviving options would report a section that did not supply the token they hold.
var services = new ServiceCollection();
IConfiguration configuration = Configuration(
("Secrets:GrpcHub:BearerToken", Token),
("Central:Secrets:Hub:MaxNamesPerRequest", "7"));
services.AddZbSecretsGrpcHub(configuration);
Assert.Throws(
() => services.AddZbSecretsGrpcHub(configuration, "Central:Secrets:Hub"));
SecretsGrpcHubServerOptions options = Resolve(services);
Assert.Equal("Secrets:GrpcHub", options.SectionPath);
Assert.Equal(Token, options.BearerToken);
Assert.Equal(1000, options.MaxNamesPerRequest);
}
private static IConfiguration Configuration(params (string Key, string Value)[] entries) =>
new ConfigurationBuilder()
.AddInMemoryCollection(entries.Select(e => new KeyValuePair(e.Key, e.Value)))
.Build();
private static SecretsGrpcHubServerOptions Resolve(IServiceCollection services)
{
using ServiceProvider provider = services.BuildServiceProvider();
return provider.GetRequiredService>().Value;
}
}