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
423 lines
17 KiB
C#
423 lines
17 KiB
C#
using System.Reflection;
|
|
using Grpc.Core;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.Secrets.Replicator.Grpc.V1;
|
|
|
|
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
|
|
|
|
/// <summary>
|
|
/// Covers the hub's authentication boundary. Every case here is a security invariant, not a
|
|
/// behaviour preference: fail-closed on an unset key, uniform denial that never says <em>why</em>,
|
|
/// and no token material in any log line.
|
|
/// </summary>
|
|
public sealed class SecretsHubAuthInterceptorTests
|
|
{
|
|
private const string ConfiguredToken = "s3cr3t-hub-token-value";
|
|
|
|
[Fact]
|
|
public async Task Valid_bearer_token_reaches_the_continuation()
|
|
{
|
|
SecretsHubAuthInterceptor interceptor = Build(ConfiguredToken, out _);
|
|
var context = HubServerCallContextStub.WithAuthorization($"Bearer {ConfiguredToken}");
|
|
bool invoked = false;
|
|
|
|
GetManifestResponse response = await interceptor.UnaryServerHandler(
|
|
new GetManifestRequest(),
|
|
context,
|
|
(_, _) =>
|
|
{
|
|
invoked = true;
|
|
return Task.FromResult(new GetManifestResponse());
|
|
});
|
|
|
|
Assert.True(invoked);
|
|
Assert.NotNull(response);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Bearer wrong-token-value")]
|
|
[InlineData("Bearer s3cr3t-hub-token-valu")] // one char short — length mismatch
|
|
[InlineData("Bearer s3cr3t-hub-token-values")] // one char long
|
|
[InlineData("Bearer S3CR3T-HUB-TOKEN-VALUE")] // case must matter
|
|
public async Task Wrong_token_is_denied(string headerValue)
|
|
{
|
|
await AssertDeniedAsync(Build(ConfiguredToken, out _), headerValue);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Missing_authorization_header_is_denied()
|
|
{
|
|
await AssertDeniedAsync(Build(ConfiguredToken, out _), header: null);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData(ConfiguredToken)] // bare token, no scheme
|
|
[InlineData("Basic " + ConfiguredToken)] // wrong scheme
|
|
[InlineData("Bearer")] // scheme only
|
|
[InlineData("Bearer ")] // scheme, empty token
|
|
[InlineData("bearer " + ConfiguredToken)] // scheme case
|
|
[InlineData("Bearer " + ConfiguredToken)] // extra space is part of the token
|
|
public async Task Malformed_authorization_header_is_denied(string headerValue)
|
|
{
|
|
await AssertDeniedAsync(Build(ConfiguredToken, out _), headerValue);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unset_configured_token_denies_a_valid_looking_request()
|
|
{
|
|
// Fail closed: with no key configured there is no credential that can be correct, so a
|
|
// well-formed bearer request is refused exactly like a malformed one.
|
|
await AssertDeniedAsync(Build(string.Empty, out _), $"Bearer {ConfiguredToken}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unset_configured_token_warns_once_naming_the_exact_config_key()
|
|
{
|
|
SecretsHubAuthInterceptor interceptor = Build(string.Empty, out HubCapturingLogger<SecretsHubAuthInterceptor> logger);
|
|
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
await AssertDeniedAsync(interceptor, $"Bearer {ConfiguredToken}");
|
|
}
|
|
|
|
List<string> warnings = logger.Entries
|
|
.Where(e => e.Level == LogLevel.Warning)
|
|
.Select(e => e.Message)
|
|
.ToList();
|
|
|
|
string warning = Assert.Single(warnings);
|
|
Assert.Contains("Secrets:GrpcHub:BearerToken", warning, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unset_token_warning_names_the_section_path_it_was_registered_under()
|
|
{
|
|
var options = new SecretsGrpcHubServerOptions { BearerToken = string.Empty };
|
|
options.SectionPath = "Central:Secrets:Hub";
|
|
var logger = new HubCapturingLogger<SecretsHubAuthInterceptor>();
|
|
var interceptor = new SecretsHubAuthInterceptor(Options.Create(options), logger);
|
|
|
|
await AssertDeniedAsync(interceptor, "Bearer anything");
|
|
|
|
Assert.Contains(
|
|
logger.Entries,
|
|
e => e.Level == LogLevel.Warning
|
|
&& e.Message.Contains("Central:Secrets:Hub:BearerToken", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Every_failure_mode_produces_the_identical_denial()
|
|
{
|
|
SecretsHubAuthInterceptor configured = Build(ConfiguredToken, out _);
|
|
SecretsHubAuthInterceptor unset = Build(string.Empty, out _);
|
|
|
|
var details = new List<string>
|
|
{
|
|
(await AssertDeniedAsync(configured, header: null)).Status.Detail,
|
|
(await AssertDeniedAsync(configured, "")).Status.Detail,
|
|
(await AssertDeniedAsync(configured, "Basic abc")).Status.Detail,
|
|
(await AssertDeniedAsync(configured, "Bearer ")).Status.Detail,
|
|
(await AssertDeniedAsync(configured, "Bearer wrong-token-value")).Status.Detail,
|
|
(await AssertDeniedAsync(unset, $"Bearer {ConfiguredToken}")).Status.Detail,
|
|
};
|
|
|
|
Assert.Single(details.Distinct(StringComparer.Ordinal));
|
|
|
|
// And it must not narrate the reason to the caller.
|
|
foreach (string detail in details)
|
|
{
|
|
foreach (string leak in new[] { "expired", "missing", "malformed", "unset", "no token", "wrong" })
|
|
{
|
|
Assert.DoesNotContain(leak, detail, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Denied_call_never_reaches_the_continuation()
|
|
{
|
|
SecretsHubAuthInterceptor interceptor = Build(ConfiguredToken, out _);
|
|
bool invoked = false;
|
|
|
|
await Assert.ThrowsAsync<RpcException>(() => interceptor.UnaryServerHandler(
|
|
new GetManifestRequest(),
|
|
HubServerCallContextStub.WithAuthorization("Bearer nope"),
|
|
(_, _) =>
|
|
{
|
|
invoked = true;
|
|
return Task.FromResult(new GetManifestResponse());
|
|
}));
|
|
|
|
Assert.False(invoked);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Server_streaming_calls_are_gated_too()
|
|
{
|
|
SecretsHubAuthInterceptor interceptor = Build(ConfiguredToken, out _);
|
|
bool invoked = false;
|
|
|
|
RpcException error = await Assert.ThrowsAsync<RpcException>(
|
|
() => interceptor.ServerStreamingServerHandler(
|
|
new 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 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()
|
|
{
|
|
const string presented = "presented-token-that-must-not-be-logged";
|
|
SecretsHubAuthInterceptor configured = Build(ConfiguredToken, out HubCapturingLogger<SecretsHubAuthInterceptor> configuredLog);
|
|
SecretsHubAuthInterceptor unset = Build(string.Empty, out HubCapturingLogger<SecretsHubAuthInterceptor> unsetLog);
|
|
|
|
await AssertDeniedAsync(configured, $"Bearer {presented}");
|
|
await AssertDeniedAsync(configured, header: null);
|
|
await configured.UnaryServerHandler(
|
|
new GetManifestRequest(),
|
|
HubServerCallContextStub.WithAuthorization($"Bearer {ConfiguredToken}"),
|
|
(_, _) => Task.FromResult(new GetManifestResponse()));
|
|
await AssertDeniedAsync(unset, $"Bearer {presented}");
|
|
|
|
foreach (string message in configuredLog.Entries.Concat(unsetLog.Entries).Select(e => e.Message))
|
|
{
|
|
Assert.DoesNotContain(ConfiguredToken, message, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain(presented, message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Interceptor_has_exactly_one_public_constructor()
|
|
{
|
|
// Grpc.AspNetCore's interceptor activator silently stops invoking an interceptor whose
|
|
// constructor is ambiguous — the failure mode is an UNAUTHENTICATED gate that quietly
|
|
// stops running, so this is pinned rather than left to review.
|
|
ConstructorInfo[] ctors = typeof(SecretsHubAuthInterceptor)
|
|
.GetConstructors(BindingFlags.Public | BindingFlags.Instance);
|
|
|
|
Assert.Single(ctors);
|
|
}
|
|
|
|
private static SecretsHubAuthInterceptor Build(string token, out HubCapturingLogger<SecretsHubAuthInterceptor> logger)
|
|
{
|
|
logger = new HubCapturingLogger<SecretsHubAuthInterceptor>();
|
|
var options = new SecretsGrpcHubServerOptions { BearerToken = token };
|
|
return new SecretsHubAuthInterceptor(Options.Create(options), logger);
|
|
}
|
|
|
|
private static async Task<RpcException> AssertDeniedAsync(
|
|
SecretsHubAuthInterceptor interceptor, string? header)
|
|
{
|
|
RpcException error = await Assert.ThrowsAsync<RpcException>(
|
|
() => interceptor.UnaryServerHandler(
|
|
new GetManifestRequest(),
|
|
HubServerCallContextStub.WithAuthorization(header),
|
|
(_, _) => Task.FromResult(new GetManifestResponse())));
|
|
|
|
Assert.Equal(StatusCode.Unauthenticated, error.StatusCode);
|
|
return error;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A minimal <see cref="ServerCallContext"/> for exercising interceptors and service methods
|
|
/// directly. Hand-rolled because <c>Grpc.Core.Testing.TestServerCallContext</c> ships in a
|
|
/// package this project deliberately does not reference.
|
|
/// </summary>
|
|
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, 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)
|
|
{
|
|
var headers = new Metadata();
|
|
if (header is not null)
|
|
{
|
|
headers.Add("Authorization", header);
|
|
}
|
|
|
|
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(), 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;
|
|
protected override Metadata ResponseTrailersCore => _responseTrailers;
|
|
protected override Status StatusCore { get; set; }
|
|
protected override WriteOptions? WriteOptionsCore { get; set; }
|
|
protected override AuthContext AuthContextCore => _authContext;
|
|
protected override IDictionary<object, object> UserStateCore => _userState;
|
|
|
|
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
|
|
throw new NotSupportedException("Context propagation is not used by the secrets hub.");
|
|
|
|
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>A no-op response stream, enough to drive the server-streaming interceptor path.</summary>
|
|
internal sealed class HubServerStreamWriterStub<T> : IServerStreamWriter<T>
|
|
{
|
|
/// <summary>Everything written, in order.</summary>
|
|
public List<T> Written { get; } = [];
|
|
|
|
/// <inheritdoc/>
|
|
public WriteOptions? WriteOptions { get; set; }
|
|
|
|
/// <inheritdoc/>
|
|
public Task WriteAsync(T message)
|
|
{
|
|
Written.Add(message);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
/// <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>
|
|
{
|
|
/// <summary>Every entry logged, formatted exactly as a sink would render it.</summary>
|
|
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
|
|
|
/// <inheritdoc/>
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
|
|
/// <inheritdoc/>
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
/// <inheritdoc/>
|
|
public void Log<TState>(
|
|
LogLevel logLevel,
|
|
EventId eventId,
|
|
TState state,
|
|
Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(formatter);
|
|
Entries.Add((logLevel, formatter(state, exception)));
|
|
}
|
|
}
|