Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsGrpcHubClientOptionsTests.cs
T
Joseph Doherty c5ea3a7b59 feat(secrets): Grpc hub client — pull-only sweep service + options + DI
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
2026-08-07 06:12:03 -04:00

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