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:
+29
-1
@@ -37,7 +37,9 @@ public static class SecretsGrpcHubServerExtensions
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
/// <param name="sectionPath">Configuration section holding the hub options.</param>
|
||||
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
||||
/// <exception cref="InvalidOperationException">No bearer token is configured.</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// No bearer token is configured, or the hub has already been registered.
|
||||
/// </exception>
|
||||
public static IServiceCollection AddZbSecretsGrpcHub(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
@@ -47,6 +49,24 @@ public static class SecretsGrpcHubServerExtensions
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||||
|
||||
// Registering twice is refused rather than tolerated, because 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 below is last-one-wins. A hub registered from
|
||||
// two sections could therefore serve a token bound from one of them while every failure
|
||||
// message names the other — a misdirection that outlives the mistake that caused it.
|
||||
if (services.Any(descriptor => descriptor.ServiceType == typeof(HubRegistrationMarker)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The gRPC secrets hub may only be registered once; AddZbSecretsGrpcHub was called " +
|
||||
"more than once. Remove the duplicate call — a second registration would merge its " +
|
||||
"configuration into the first and leave the reported section path pointing at " +
|
||||
"whichever call ran last.");
|
||||
}
|
||||
|
||||
// Registered as an instance so nothing ever has to activate it: the marker exists to be
|
||||
// looked for, not resolved.
|
||||
services.AddSingleton(new HubRegistrationMarker());
|
||||
|
||||
IConfigurationSection section = configuration.GetSection(sectionPath);
|
||||
services.Configure<SecretsGrpcHubServerOptions>(section);
|
||||
|
||||
@@ -84,4 +104,12 @@ public static class SecretsGrpcHubServerExtensions
|
||||
endpoints.MapGrpcService<SecretsHubService>();
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presence of this in the container means the hub is already registered. It carries no state
|
||||
/// and is never resolved — a dedicated type is used rather than probing for one of the real
|
||||
/// registrations because every one of those is added with <c>TryAdd</c>-style semantics that a
|
||||
/// host may legitimately have satisfied for its own reasons.
|
||||
/// </summary>
|
||||
private sealed class HubRegistrationMarker;
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@ option csharp_namespace = "ZB.MOM.WW.Secrets.Replicator.Grpc.V1";
|
||||
// The central-hosted secrets hub, as seen by a site node.
|
||||
//
|
||||
// PULL-ONLY BY CONSTRUCTION. This service mirrors exactly the read half of
|
||||
// ISecretReplicationHub — GetManifestAsync and GetManyAsync. Its third method,
|
||||
// ApplyReplicatedAsync, is deliberately NOT mirrored: in this topology every update
|
||||
// originates at central, and a site node is a strict follower. The narrow contract IS the
|
||||
// enforcement — with no write RPC on the wire, a mis-wired or compromised site node has no
|
||||
// way to push a row into the hub, regardless of what its authorization happens to allow.
|
||||
// ISecretReplicationHub, method for method: GetManifestAsync -> GetManifest, and
|
||||
// GetManyAsync -> GetSecrets (the RPC is named for what it returns rather than for the
|
||||
// .NET method it fronts). Its third method, ApplyReplicatedAsync, is deliberately NOT
|
||||
// mirrored: in this topology every update originates at central, and a site node is a
|
||||
// strict follower. The narrow contract IS the enforcement — with no write RPC on the wire,
|
||||
// a mis-wired or compromised site node has no way to push a row into the hub, regardless of
|
||||
// what its authorization happens to allow.
|
||||
// Adding a third RPC to this service would silently retire that guarantee, so do not.
|
||||
//
|
||||
// Everything crossing this boundary is ciphertext or metadata about ciphertext. The
|
||||
@@ -33,6 +35,9 @@ service SecretsHub {
|
||||
// Fetches the named encrypted rows in bulk. Names absent from the hub are omitted from
|
||||
// the response rather than reported as errors — the caller's manifest may be a moment
|
||||
// stale, and a row deleted between the two calls is not a fault.
|
||||
//
|
||||
// The server caps how many names one call may carry and answers RESOURCE_EXHAUSTED above
|
||||
// it, so a caller must chunk large sweeps rather than send one request per manifest.
|
||||
rpc GetSecrets(GetSecretsRequest) returns (GetSecretsResponse);
|
||||
}
|
||||
|
||||
|
||||
+30
-1
@@ -22,12 +22,30 @@ public sealed class SecretsGrpcHubServerOptions
|
||||
/// <summary>The configuration section the hub is bound from unless a host overrides it.</summary>
|
||||
internal const string DefaultSectionPath = "Secrets:GrpcHub";
|
||||
|
||||
/// <summary>
|
||||
/// The default ceiling on names in one <c>GetSecrets</c> call. Set comfortably above the
|
||||
/// follower's own fetch chunk so a compliant sweep never approaches it.
|
||||
/// </summary>
|
||||
internal const int DefaultMaxNamesPerRequest = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// The shared bearer token a follower must present. Required — an unset token is not "open
|
||||
/// access", it is a hub that refuses every call (see <see cref="SecretsHubAuthInterceptor"/>).
|
||||
/// </summary>
|
||||
public string BearerToken { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The most names a single <c>GetSecrets</c> call may carry. A larger request is refused with
|
||||
/// <c>ResourceExhausted</c> rather than served.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The well-behaved follower already chunks its fetches well below this, so the cap is not a
|
||||
/// throughput knob — it is the server declining to let one authenticated caller turn a single
|
||||
/// request into an unbounded sequential store sweep. The cap sits behind authentication, so it
|
||||
/// is defence in depth against a misbehaving token holder, not an anonymous-DoS control.
|
||||
/// </remarks>
|
||||
public int MaxNamesPerRequest { get; set; } = DefaultMaxNamesPerRequest;
|
||||
|
||||
/// <summary>
|
||||
/// The configuration section this instance was bound from, used so failure messages name the
|
||||
/// key an operator actually has to set.
|
||||
@@ -43,7 +61,9 @@ public sealed class SecretsGrpcHubServerOptions
|
||||
/// Throws if the options are unusable. Called by the registration extension so a hub with no
|
||||
/// token fails at startup, rather than starting and rejecting every follower at runtime.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The bearer token is missing or blank.</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The bearer token is missing or blank, or the request cap is not positive.
|
||||
/// </exception>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(BearerToken))
|
||||
@@ -54,5 +74,14 @@ public sealed class SecretsGrpcHubServerOptions
|
||||
$"The gRPC secrets hub requires a shared bearer token ({SectionPath}:BearerToken). " +
|
||||
"Until it is set the hub refuses every call.");
|
||||
}
|
||||
|
||||
if (MaxNamesPerRequest <= 0)
|
||||
{
|
||||
// A zero or negative cap would refuse every GetSecrets call, which reads at runtime like
|
||||
// a broken follower rather than a typo in configuration — so it is refused at startup.
|
||||
throw new InvalidOperationException(
|
||||
$"The gRPC secrets hub request cap ({SectionPath}:MaxNamesPerRequest) must be " +
|
||||
$"greater than zero; it is {MaxNamesPerRequest}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
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;
|
||||
@@ -27,14 +28,30 @@ public sealed class SecretsHubService : SecretsHub.SecretsHubBase
|
||||
{
|
||||
private readonly ISecretStore _store;
|
||||
private readonly ILogger<SecretsHubService> _logger;
|
||||
private readonly int _maxNamesPerRequest;
|
||||
|
||||
/// <summary>Creates the hub service over the node's secret store.</summary>
|
||||
/// <param name="store">The store to serve. Only its read members are used.</param>
|
||||
/// <param name="options">The hub options, read for the per-request name cap.</param>
|
||||
/// <param name="logger">Diagnostics sink.</param>
|
||||
public SecretsHubService(ISecretStore store, ILogger<SecretsHubService> logger)
|
||||
public SecretsHubService(
|
||||
ISecretStore store,
|
||||
IOptions<SecretsGrpcHubServerOptions> options,
|
||||
ILogger<SecretsHubService> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
// The registration extension validates the cap, so a non-positive value here means the
|
||||
// options were built some other way. Falling back to the default is the safe direction: the
|
||||
// alternative reading of "0" is a hub with no cap at all, which is the thing the cap exists
|
||||
// to prevent.
|
||||
int configured = options.Value.MaxNamesPerRequest;
|
||||
_maxNamesPerRequest = configured > 0
|
||||
? configured
|
||||
: SecretsGrpcHubServerOptions.DefaultMaxNamesPerRequest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -66,21 +83,47 @@ public sealed class SecretsHubService : SecretsHub.SecretsHubBase
|
||||
/// Fetches the named encrypted rows in bulk.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A requested name the hub does not hold is <b>omitted</b> from the response, and an unparseable
|
||||
/// name is <b>skipped</b> — neither is an error. Both describe the same ordinary situation: the
|
||||
/// follower is acting on a manifest that was true a moment ago. Failing the whole call would turn
|
||||
/// a routine race into a stalled sweep, and would let one bad name deny the follower every other
|
||||
/// row in the batch.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A request carrying more than <see cref="SecretsGrpcHubServerOptions.MaxNamesPerRequest"/>
|
||||
/// names <b>is</b> refused, before any read runs. That is not the stale-manifest case: it is a
|
||||
/// caller asking the hub to walk its store an unbounded number of times inside one call, and the
|
||||
/// server declines independently of whatever chunking the client believes it is doing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="request">The names to fetch.</param>
|
||||
/// <param name="context">The call context.</param>
|
||||
/// <returns>One row per distinct name the hub holds.</returns>
|
||||
/// <exception cref="RpcException">The request carries more names than the configured cap.</exception>
|
||||
public override async Task<GetSecretsResponse> GetSecrets(
|
||||
GetSecretsRequest request, ServerCallContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (request.Names.Count > _maxNamesPerRequest)
|
||||
{
|
||||
// The cap and the count are both safe to state — neither is secret, and an operator
|
||||
// cannot fix a batch that is too large without knowing by how much. The names are NOT
|
||||
// stated: they are the one part of this request that identifies secrets.
|
||||
_logger.LogWarning(
|
||||
"Secrets hub refused a GetSecrets request of {RequestedCount} name(s); the cap is " +
|
||||
"{MaxNamesPerRequest}.",
|
||||
request.Names.Count,
|
||||
_maxNamesPerRequest);
|
||||
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.ResourceExhausted,
|
||||
$"GetSecrets accepts at most {_maxNamesPerRequest} names per request; this one " +
|
||||
$"carried {request.Names.Count}. Split the fetch into smaller batches."));
|
||||
}
|
||||
|
||||
var response = new GetSecretsResponse();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
int skipped = 0;
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
+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>
|
||||
{
|
||||
|
||||
+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