fix(secrets): Grpc hub server review follow-ups — GetSecrets batch cap, DI single-registration guard + tests

Five review items on the Task 2 server half.

1. GetSecrets now enforces a server-side cap on request.Names, refusing an
   over-cap batch with ResourceExhausted BEFORE any store read runs. The
   well-behaved follower chunks at 500, but client discipline is not a server
   defence — the family precedent (HistorianGateway's max_values) is that the
   server declines regardless. The new SecretsGrpcHubServerOptions.
   MaxNamesPerRequest defaults to 1000, comfortably above that chunk so a
   compliant sweep never meets it, and Validate() refuses a non-positive value
   at startup rather than letting it read at runtime as a broken follower. The
   refusal states the cap and the received count — neither is secret, and an
   operator cannot resize a batch without them — and never a name. The cap sits
   behind the interceptor, so it is defence in depth against a misbehaving
   token holder, not an anonymous-DoS control.

2. AddZbSecretsGrpcHub now refuses a second registration. The two calls do not
   compose: options binding MERGES (a key absent from the second section leaves
   the first section's value standing) while the SectionPath stamp is
   last-one-wins, so a hub registered twice could serve a token bound from one
   section while every failure message named the other. Detected via a private
   marker registration. The extension also gains real DI coverage — the intended
   single-call path asserted for token/SectionPath coherence through a built
   provider, a custom section path stamped end to end, a missing token throwing
   from the registration call itself, and the duplicate call throwing.

3. Client-streaming and duplex-streaming denial tests, so all four gated handler
   shapes are pinned rather than only unary and server-streaming, plus a
   valid-token test that reaches all three streaming continuations.

4. A cancellation test: GetSecrets hands the ServerCallContext token through to
   the store, pinned against a fake that records it.

5. The proto's service comment stated the ISecretReplicationHub mapping in prose
   that no longer matched the RPC name; it now says GetManifestAsync ->
   GetManifest and GetManyAsync -> GetSecrets explicitly. Comment-only — no
   message or rpc definition is touched. The GetSecrets rpc comment also notes
   the new RESOURCE_EXHAUSTED cap, which a client author has to know about.

Grpc server suite 50 -> 73 tests; full solution suite green, 0 new warnings.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
Joseph Doherty
2026-08-07 06:27:16 -04:00
parent 7fc77f2790
commit 353af72265
7 changed files with 531 additions and 15 deletions
@@ -1,3 +1,8 @@
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;
/// <summary>
@@ -71,4 +76,194 @@ public sealed class SecretsGrpcHubServerOptionsTests
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<InvalidOperationException>(() => 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<InvalidOperationException>(() => options.Validate());
Assert.Contains("Central:Secrets:Hub:MaxNamesPerRequest", error.Message, StringComparison.Ordinal);
}
}
/// <summary>
/// The registration extension itself, driven through a real <see cref="ServiceCollection"/> and a
/// real <see cref="IConfiguration"/>. 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.
/// </summary>
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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<string, string?>(e.Key, e.Value)))
.Build();
private static SecretsGrpcHubServerOptions Resolve(IServiceCollection services)
{
using ServiceProvider provider = services.BuildServiceProvider();
return provider.GetRequiredService<IOptions<SecretsGrpcHubServerOptions>>().Value;
}
}
@@ -155,7 +155,7 @@ public sealed class SecretsHubAuthInterceptorTests
}
[Fact]
public async Task Non_unary_calls_are_gated_too()
public async Task Server_streaming_calls_are_gated_too()
{
SecretsHubAuthInterceptor interceptor = Build(ConfiguredToken, out _);
bool invoked = false;
@@ -175,6 +175,88 @@ public sealed class SecretsHubAuthInterceptorTests
Assert.False(invoked);
}
[Fact]
public async Task Client_streaming_calls_are_gated_too()
{
// The contract is unary-only today, so all four handler shapes are covered deliberately:
// an overload left ungated would be discovered by the first RPC that used it, in production.
SecretsHubAuthInterceptor interceptor = Build(ConfiguredToken, out _);
bool invoked = false;
RpcException error = await Assert.ThrowsAsync<RpcException>(
() => interceptor.ClientStreamingServerHandler(
new HubAsyncStreamReaderStub<GetSecretsRequest>(),
HubServerCallContextStub.WithAuthorization(header: null),
(_, _) =>
{
invoked = true;
return Task.FromResult(new GetSecretsResponse());
}));
Assert.Equal(StatusCode.Unauthenticated, error.StatusCode);
Assert.False(invoked);
}
[Fact]
public async Task Duplex_streaming_calls_are_gated_too()
{
SecretsHubAuthInterceptor interceptor = Build(ConfiguredToken, out _);
bool invoked = false;
RpcException error = await Assert.ThrowsAsync<RpcException>(
() => interceptor.DuplexStreamingServerHandler(
new HubAsyncStreamReaderStub<GetSecretsRequest>(),
new HubServerStreamWriterStub<GetSecretsResponse>(),
HubServerCallContextStub.WithAuthorization(header: null),
(_, _, _) =>
{
invoked = true;
return Task.CompletedTask;
}));
Assert.Equal(StatusCode.Unauthenticated, error.StatusCode);
Assert.False(invoked);
}
[Fact]
public async Task A_valid_token_reaches_every_streaming_continuation()
{
SecretsHubAuthInterceptor interceptor = Build(ConfiguredToken, out _);
string header = $"Bearer {ConfiguredToken}";
var reached = new List<string>();
await interceptor.ServerStreamingServerHandler(
new GetSecretsRequest(),
new HubServerStreamWriterStub<GetSecretsResponse>(),
HubServerCallContextStub.WithAuthorization(header),
(_, _, _) =>
{
reached.Add("server");
return Task.CompletedTask;
});
await interceptor.ClientStreamingServerHandler(
new HubAsyncStreamReaderStub<GetSecretsRequest>(),
HubServerCallContextStub.WithAuthorization(header),
(_, _) =>
{
reached.Add("client");
return Task.FromResult(new GetSecretsResponse());
});
await interceptor.DuplexStreamingServerHandler(
new HubAsyncStreamReaderStub<GetSecretsRequest>(),
new HubServerStreamWriterStub<GetSecretsResponse>(),
HubServerCallContextStub.WithAuthorization(header),
(_, _, _) =>
{
reached.Add("duplex");
return Task.CompletedTask;
});
Assert.Equal(["server", "client", "duplex"], reached);
}
[Fact]
public async Task No_log_line_ever_contains_token_material()
{
@@ -238,11 +320,16 @@ public sealed class SecretsHubAuthInterceptorTests
internal sealed class HubServerCallContextStub : ServerCallContext
{
private readonly Metadata _requestHeaders;
private readonly CancellationToken _cancellationToken;
private readonly Metadata _responseTrailers = new();
private readonly AuthContext _authContext = new(null, new Dictionary<string, List<AuthProperty>>());
private readonly Dictionary<object, object> _userState = new();
private HubServerCallContextStub(Metadata requestHeaders) => _requestHeaders = requestHeaders;
private HubServerCallContextStub(Metadata requestHeaders, CancellationToken cancellationToken)
{
_requestHeaders = requestHeaders;
_cancellationToken = cancellationToken;
}
/// <summary>Builds a context carrying the given <c>Authorization</c> header, or none when null.</summary>
public static HubServerCallContextStub WithAuthorization(string? header)
@@ -253,18 +340,22 @@ internal sealed class HubServerCallContextStub : ServerCallContext
headers.Add("Authorization", header);
}
return new HubServerCallContextStub(headers);
return new HubServerCallContextStub(headers, CancellationToken.None);
}
/// <summary>Builds a context with no metadata at all, for service-level tests.</summary>
public static HubServerCallContextStub Empty() => new(new Metadata());
public static HubServerCallContextStub Empty() => new(new Metadata(), CancellationToken.None);
/// <summary>Builds a context whose call token is the given one, for propagation tests.</summary>
public static HubServerCallContextStub WithCancellation(CancellationToken cancellationToken) =>
new(new Metadata(), cancellationToken);
protected override string MethodCore => "/zb.mom.ww.secrets.hub.v1.SecretsHub/Test";
protected override string HostCore => "localhost";
protected override string PeerCore => "ipv4:127.0.0.1:0";
protected override DateTime DeadlineCore => DateTime.UtcNow.AddSeconds(30);
protected override Metadata RequestHeadersCore => _requestHeaders;
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
protected override CancellationToken CancellationTokenCore => _cancellationToken;
protected override Metadata ResponseTrailersCore => _responseTrailers;
protected override Status StatusCore { get; set; }
protected override WriteOptions? WriteOptionsCore { get; set; }
@@ -294,6 +385,17 @@ internal sealed class HubServerStreamWriterStub<T> : IServerStreamWriter<T>
}
}
/// <summary>An empty request stream, enough to drive the two client-reading interceptor paths.</summary>
internal sealed class HubAsyncStreamReaderStub<T> : IAsyncStreamReader<T>
where T : class
{
/// <inheritdoc/>
public T Current => throw new InvalidOperationException("The stub stream carries no messages.");
/// <inheritdoc/>
public Task<bool> MoveNext(CancellationToken cancellationToken) => Task.FromResult(false);
}
/// <summary>Captures formatted log messages so tests can assert on what was — and was not — logged.</summary>
internal sealed class HubCapturingLogger<TCategory> : ILogger<TCategory>
{
@@ -1,6 +1,8 @@
using System.Globalization;
using Google.Protobuf;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.Grpc.V1;
using ZB.MOM.WW.Secrets.Replicator.Grpc.Wire;
@@ -142,6 +144,106 @@ public sealed class SecretsHubServiceTests
Assert.Equal(0, store.GetCallCount);
}
[Fact]
public async Task GetSecrets_serves_a_request_exactly_at_the_cap()
{
var store = new HubFakeSecretStore();
store.Seed(Row("n1"));
store.Seed(Row("n2"));
store.Seed(Row("n3"));
var request = new GetSecretsRequest { Names = { "n1", "n2", "n3" } };
GetSecretsResponse response = await Service(store, maxNamesPerRequest: 3)
.GetSecrets(request, HubServerCallContextStub.Empty());
Assert.Equal(3, response.Secrets.Count);
}
[Fact]
public async Task GetSecrets_refuses_a_request_over_the_cap()
{
var store = new HubFakeSecretStore();
store.Seed(Row("n1"));
var request = new GetSecretsRequest { Names = { "n1", "n2", "n3", "n4" } };
RpcException error = await Assert.ThrowsAsync<RpcException>(
() => Service(store, maxNamesPerRequest: 3).GetSecrets(request, HubServerCallContextStub.Empty()));
Assert.Equal(StatusCode.ResourceExhausted, error.StatusCode);
}
[Fact]
public async Task GetSecrets_over_the_cap_reads_nothing_at_all()
{
// The refusal has to land BEFORE the sequential store loop — a cap that stops the response
// after doing the reads would defend nothing.
var store = new HubFakeSecretStore();
store.Seed(Row("n1"));
var request = new GetSecretsRequest { Names = { "n1", "n1", "n1" } };
await Assert.ThrowsAsync<RpcException>(
() => Service(store, maxNamesPerRequest: 2).GetSecrets(request, HubServerCallContextStub.Empty()));
Assert.Equal(0, store.GetCallCount);
}
[Fact]
public async Task GetSecrets_cap_refusal_states_the_cap_and_the_count_but_never_a_name()
{
var store = new HubFakeSecretStore();
var request = new GetSecretsRequest { Names = { "db/central", "db/site-a", "db/site-b" } };
RpcException error = await Assert.ThrowsAsync<RpcException>(
() => Service(store, maxNamesPerRequest: 2).GetSecrets(request, HubServerCallContextStub.Empty()));
Assert.Contains("2", error.Status.Detail, StringComparison.Ordinal);
Assert.Contains("3", error.Status.Detail, StringComparison.Ordinal);
foreach (string name in request.Names)
{
Assert.DoesNotContain(name, error.Status.Detail, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public async Task GetSecrets_falls_back_to_the_default_cap_when_options_carry_a_nonpositive_one()
{
// Registration validates the cap, so this only happens when the options were built some
// other way — and the safe reading of "0" is the default, never "no cap".
var store = new HubFakeSecretStore();
var request = new GetSecretsRequest();
for (int i = 0; i <= SecretsGrpcHubServerOptions.DefaultMaxNamesPerRequest; i++)
{
request.Names.Add($"n{i}");
}
RpcException error = await Assert.ThrowsAsync<RpcException>(
() => Service(store, maxNamesPerRequest: 0).GetSecrets(request, HubServerCallContextStub.Empty()));
Assert.Equal(StatusCode.ResourceExhausted, error.StatusCode);
}
[Fact]
public async Task GetSecrets_hands_the_call_cancellation_token_to_the_store()
{
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
var store = new HubFakeSecretStore();
store.Seed(Row("present"));
await Service(store).GetSecrets(
new GetSecretsRequest { Names = { "present" } },
HubServerCallContextStub.WithCancellation(cts.Token));
// The fake does not honour the token, so what is pinned here is the propagation itself: a
// real store sees the caller's cancellation, and an abandoned call stops costing reads.
Assert.True(store.LastCancellationToken.IsCancellationRequested);
Assert.Equal(cts.Token, store.LastCancellationToken);
}
[Fact]
public void DomainToWire_maps_a_fully_populated_row_field_by_field()
{
@@ -231,8 +333,16 @@ public sealed class SecretsHubServiceTests
Assert.Equal(expected, wire.ContentType);
}
private static SecretsHubService Service(ISecretStore store) =>
new(store, NullLogger<SecretsHubService>.Instance);
private static SecretsHubService Service(ISecretStore store, int? maxNamesPerRequest = null)
{
var options = new SecretsGrpcHubServerOptions { BearerToken = "irrelevant-to-the-service" };
if (maxNamesPerRequest is int max)
{
options.MaxNamesPerRequest = max;
}
return new SecretsHubService(store, Options.Create(options), NullLogger<SecretsHubService>.Instance);
}
private static StoredSecret Row(string name, bool isDeleted = false) => new()
{
@@ -266,6 +376,9 @@ internal sealed class HubFakeSecretStore : ISecretStore
/// <summary>How many single-row reads the service issued.</summary>
public int GetCallCount { get; private set; }
/// <summary>The cancellation token the service passed on the most recent read.</summary>
public CancellationToken LastCancellationToken { get; private set; }
/// <summary>Adds a row, and the manifest entry derived from it.</summary>
public void Seed(StoredSecret row) => _rows[row.Name.Value] = row;
@@ -276,6 +389,7 @@ internal sealed class HubFakeSecretStore : ISecretStore
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct)
{
GetCallCount++;
LastCancellationToken = ct;
return Task.FromResult(_rows.GetValueOrDefault(name.Value));
}