7fc77f2790
The server half of the central-hosted pull-only secrets hub. SecretsHubService adapts the node's own ISecretStore onto secrets_hub.v1 using exactly two read members (GetManifestAsync, GetAsync) — the proto exposes no write RPC, so a follower has no path to put a row into central whatever its credentials allow. GetSecrets omits a name the hub does not hold and skips one that will not parse as a SecretName; neither is an error, because both describe a follower acting on a manifest that was true a moment ago. Failing the call would stall the sweep and let one bad name deny the follower every other row in the batch. Names are re-validated server-side (the wire type is a plain string) and de-duplicated on the normalized value. DomainToWire mirrors the Akka replicator's SecretRowDto discipline exactly: round-trip "O" timestamps so a DateTimeOffset's offset and ticks survive the last-writer-wins equality compare, the content type as its enum MEMBER NAME so an unknown value is rejected rather than decoded to the zero member, and nullable fields left absent rather than written as "" so null and empty stay distinct. SecretsHubAuthInterceptor fails closed: with no token configured every call is denied and one warning names the exact configuration key — never a token value, presented or configured, at any level. Denial is uniform (one Status, one detail, no "why") so a caller gets no oracle. Comparison is constant-time over UTF-8 bytes. All four handler shapes are gated, not just unary, so a future RPC cannot arrive unauthenticated by omission; more than one Authorization header is refused as a smuggling shape. The type keeps EXACTLY ONE public constructor — Grpc's activator silently stops invoking an interceptor it cannot construct unambiguously, and the failure mode is an auth gate that quietly does not run — so a test pins the count. Tests: 50, red-first (compile-red, then a fail-closed mutation verified 5 of them bite). Full Secrets suite 328 passed / 0 failed / 15 live-gated skips, 0 new warnings. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
75 lines
2.6 KiB
C#
75 lines
2.6 KiB
C#
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => options.Validate());
|
|
}
|
|
|
|
[Fact]
|
|
public void Section_path_defaults_to_the_documented_convention()
|
|
{
|
|
var options = new SecretsGrpcHubServerOptions();
|
|
|
|
Assert.Equal("Secrets:GrpcHub", options.SectionPath);
|
|
}
|
|
}
|