c5ea3a7b59
Client half of the central-hosted secrets hub: a site node follows central and never pushes. - `ISecretsHubReader` + `GrpcSecretsHubClient` — the read half of the hub as a follower sees it (manifest + bulk fetch, nothing else). Bearer credential and a per-call deadline are attached per call; responses convert through `WireToDomain`. Faults are deliberately not caught here, so an unreachable hub can never be mistaken for an empty one. - `WireToDomain` — strict, per-row proto -> domain parse mirroring the Akka replicator's `SecretRowDto`: a name the domain would reject, a content type naming no enum member, or a non-round-trip timestamp is refused at the boundary. Explicit proto3 presence keeps null distinguishable from empty. - `GrpcSecretSyncService` — `SqlServerSecretSyncService` with the push half removed. One `ReconcileAsync` per tick (pull-only by the reconciler's own contract, and batched, so a cold-start pull set cannot exceed gRPC's 4 MB cap), never throws out of `ExecuteAsync`, warns once and retries on the next interval. The local store is left completely undecorated — the sweep is its only hub-ward writer. - `SecretsGrpcHubClientOptions` — fail-closed `Validate()` naming the exact configuration key, including the section a caller actually bound. The bearer token is never echoed by any message. - `AddZbSecretsGrpcHubClient` — binds, eagerly validates, and registers the channel under a service KEY so it cannot lose a `TryAdd` race to an application's own `GrpcChannel` and dial the wrong endpoint with the hub credential attached. Tests 52/52 (red-first), including a source-scan guard pinning that no client file names `ComputePushSet`, `ReadLocalAsync`, or `ApplyReplicatedAsync`, and a reflection pin that the reader seam exposes exactly two read operations. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
133 lines
4.8 KiB
C#
133 lines
4.8 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),
|
|
];
|
|
|
|
[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 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);
|
|
}
|
|
}
|