353af72265
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
270 lines
9.8 KiB
C#
270 lines
9.8 KiB
C#
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>
|
|
/// Registration-time validation for the hub server. A misconfigured hub must fail at startup with
|
|
/// a message naming the exact configuration key, not serve unauthenticated at runtime.
|
|
/// </summary>
|
|
public sealed class SecretsGrpcHubServerOptionsTests
|
|
{
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData("\t\n")]
|
|
public void Validate_throws_when_the_bearer_token_is_unset(string token)
|
|
{
|
|
var options = new SecretsGrpcHubServerOptions { BearerToken = token };
|
|
|
|
InvalidOperationException error =
|
|
Assert.Throws<InvalidOperationException>(() => options.Validate());
|
|
|
|
Assert.Contains("Secrets:GrpcHub:BearerToken", error.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_names_the_section_path_the_hub_was_registered_under()
|
|
{
|
|
var options = new SecretsGrpcHubServerOptions { BearerToken = string.Empty };
|
|
options.SectionPath = "Central:Secrets:Hub";
|
|
|
|
InvalidOperationException error =
|
|
Assert.Throws<InvalidOperationException>(() => options.Validate());
|
|
|
|
Assert.Contains("Central:Secrets:Hub:BearerToken", error.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_accepts_a_configured_token()
|
|
{
|
|
var options = new SecretsGrpcHubServerOptions { BearerToken = "a-configured-hub-token" };
|
|
|
|
options.Validate();
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_never_echoes_the_configured_token()
|
|
{
|
|
// A token that is present but only whitespace-padded is still a real credential; the
|
|
// failure message must never carry it.
|
|
const string token = " ";
|
|
var options = new SecretsGrpcHubServerOptions { BearerToken = token };
|
|
|
|
InvalidOperationException error =
|
|
Assert.Throws<InvalidOperationException>(() => options.Validate());
|
|
|
|
Assert.DoesNotContain($"'{token}'", error.Message, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("was ", error.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bearer_token_defaults_to_empty_so_an_unconfigured_hub_cannot_serve()
|
|
{
|
|
var options = new SecretsGrpcHubServerOptions();
|
|
|
|
Assert.Equal(string.Empty, options.BearerToken);
|
|
Assert.Throws<InvalidOperationException>(() => options.Validate());
|
|
}
|
|
|
|
[Fact]
|
|
public void Section_path_defaults_to_the_documented_convention()
|
|
{
|
|
var options = new SecretsGrpcHubServerOptions();
|
|
|
|
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;
|
|
}
|
|
}
|