Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsGrpcHubServerOptionsTests.cs
T
Joseph Doherty 353af72265 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
2026-08-07 06:27:16 -04:00

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;
}
}