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:
+116
-2
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user