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:
+107
-5
@@ -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>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user