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