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;
///
/// 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 why,
/// and no token material in any log line.
///
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 logger);
for (int i = 0; i < 3; i++)
{
await AssertDeniedAsync(interceptor, $"Bearer {ConfiguredToken}");
}
// Scoped to the CONFIGURATION warning. The denial summary is a second, deliberately
// separate warning on this same path (one says "set a key", the other says "callers are
// being turned away"), so counting every Warning would no longer measure what this pins.
List warnings = logger.Entries
.Where(e => e.Level == LogLevel.Warning
&& e.Message.Contains("no bearer token configured", StringComparison.Ordinal))
.Select(e => e.Message)
.ToList();
string warning = Assert.Single(warnings);
Assert.Contains("Secrets:GrpcHub:BearerToken", warning, StringComparison.Ordinal);
// …and the denial summary is still rate-limited to one line across the same three calls.
Assert.Single(DenialWarnings(logger));
}
[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();
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
{
(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(() => 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(
() => interceptor.ServerStreamingServerHandler(
new GetSecretsRequest(),
new HubServerStreamWriterStub(),
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(
() => interceptor.ClientStreamingServerHandler(
new HubAsyncStreamReaderStub(),
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(
() => interceptor.DuplexStreamingServerHandler(
new HubAsyncStreamReaderStub(),
new HubServerStreamWriterStub(),
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();
await interceptor.ServerStreamingServerHandler(
new GetSecretsRequest(),
new HubServerStreamWriterStub(),
HubServerCallContextStub.WithAuthorization(header),
(_, _, _) =>
{
reached.Add("server");
return Task.CompletedTask;
});
await interceptor.ClientStreamingServerHandler(
new HubAsyncStreamReaderStub(),
HubServerCallContextStub.WithAuthorization(header),
(_, _) =>
{
reached.Add("client");
return Task.FromResult(new GetSecretsResponse());
});
await interceptor.DuplexStreamingServerHandler(
new HubAsyncStreamReaderStub(),
new HubServerStreamWriterStub(),
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 configuredLog);
SecretsHubAuthInterceptor unset = Build(string.Empty, out HubCapturingLogger 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);
}
}
// ---- the rate-limited denial warning (live-gate finding: denials were logged nowhere) ----
[Fact]
public async Task The_first_denial_warns_immediately()
{
// A single probe has to be visible. The rate limit bounds a flood; it must not swallow the
// one denial an operator is most likely to be looking for.
SecretsHubAuthInterceptor interceptor = Build(
ConfiguredToken, out HubCapturingLogger logger, out _);
await AssertDeniedAsync(interceptor, "Bearer wrong-token-value");
string warning = Assert.Single(DenialWarnings(logger));
Assert.Contains("denied 1", warning, StringComparison.Ordinal);
}
[Fact]
public async Task A_second_denial_inside_the_window_does_not_warn_again()
{
SecretsHubAuthInterceptor interceptor = Build(
ConfiguredToken, out HubCapturingLogger logger, out HubFakeTimeProvider clock);
await AssertDeniedAsync(interceptor, "Bearer wrong-token-value");
clock.Advance(TimeSpan.FromSeconds(59));
await AssertDeniedAsync(interceptor, "Bearer wrong-token-value");
Assert.Single(DenialWarnings(logger));
}
[Fact]
public async Task A_denial_after_the_window_warns_and_reports_what_was_suppressed()
{
SecretsHubAuthInterceptor interceptor = Build(
ConfiguredToken, out HubCapturingLogger logger, out HubFakeTimeProvider clock);
await AssertDeniedAsync(interceptor, "Bearer wrong-token-value"); // warns: 1
for (int i = 0; i < 4; i++)
{
await AssertDeniedAsync(interceptor, "Bearer wrong-token-value"); // suppressed: 4
}
clock.Advance(TimeSpan.FromSeconds(61));
await AssertDeniedAsync(interceptor, "Bearer wrong-token-value"); // warns: 4 + 1
List warnings = DenialWarnings(logger);
Assert.Equal(2, warnings.Count);
Assert.Contains("denied 1", warnings[0], StringComparison.Ordinal);
Assert.Contains("denied 5", warnings[1], StringComparison.Ordinal);
}
[Fact]
public async Task The_warning_breaks_the_denials_down_by_category()
{
SecretsHubAuthInterceptor interceptor = Build(
ConfiguredToken, out HubCapturingLogger logger, out HubFakeTimeProvider clock);
// The first denial opens the window and is drained on its own, so the counts asserted below
// are those of the SECOND window: everything from the "Basic abc" through the closing one.
await AssertDeniedAsync(interceptor, header: null); // missing — emits (1)
await AssertDeniedAsync(interceptor, "Basic abc"); // malformed
await AssertDeniedAsync(interceptor, "Bearer wrong-token-value"); // wrong
await AssertDeniedAsync(interceptor, "Bearer also-wrong"); // wrong
await AssertDeniedAsync(interceptor, "Bearer still-wrong"); // wrong
clock.Advance(TimeSpan.FromSeconds(61));
await AssertDeniedAsync(interceptor, header: null); // missing — emits (5)
string summary = DenialWarnings(logger)[1];
Assert.Contains("denied 5", summary, StringComparison.Ordinal);
Assert.Contains("missing or malformed credential: 2", summary, StringComparison.Ordinal);
Assert.Contains("wrong credential: 3", summary, StringComparison.Ordinal);
Assert.Contains("hub token unconfigured: 0", summary, StringComparison.Ordinal);
}
[Fact]
public async Task A_denial_on_an_unconfigured_hub_counts_as_unconfigured()
{
// The warn-once config signal and the denial summary are different signals: the first says
// "an operator has to set a key", the second says "callers are being turned away right now".
SecretsHubAuthInterceptor interceptor = Build(
string.Empty, out HubCapturingLogger logger, out HubFakeTimeProvider clock);
await AssertDeniedAsync(interceptor, $"Bearer {ConfiguredToken}");
await AssertDeniedAsync(interceptor, $"Bearer {ConfiguredToken}");
clock.Advance(TimeSpan.FromSeconds(61));
await AssertDeniedAsync(interceptor, $"Bearer {ConfiguredToken}");
string summary = DenialWarnings(logger)[1];
Assert.Contains("denied 2", summary, StringComparison.Ordinal);
Assert.Contains("hub token unconfigured: 2", summary, StringComparison.Ordinal);
Assert.Contains("missing or malformed credential: 0", summary, StringComparison.Ordinal);
// …and the unset-token warning is still emitted exactly once, unaffected.
Assert.Single(
logger.Entries,
e => e.Level == LogLevel.Warning
&& e.Message.Contains("no bearer token configured", StringComparison.Ordinal));
}
[Fact]
public async Task A_concurrent_burst_emits_exactly_one_warning()
{
// The endpoint is unauthenticated, so the denial path is reachable by anyone at any rate;
// a per-denial warning would be an unbounded log write driven by an unauthenticated caller.
SecretsHubAuthInterceptor interceptor = Build(
ConfiguredToken, out HubCapturingLogger logger, out _);
const int callers = 64;
using var gate = new Barrier(callers);
await Task.WhenAll(Enumerable.Range(0, callers).Select(_ => Task.Run(async () =>
{
gate.SignalAndWait();
await AssertDeniedAsync(interceptor, "Bearer wrong-token-value");
})));
// Exactly one line, whatever the interleaving. The COUNT it reports is deliberately not
// asserted: a denial that increments after the winning thread drained the counter is carried
// into the next window rather than lost, so any value in 1..callers is correct here.
Assert.Single(DenialWarnings(logger));
}
[Fact]
public async Task The_denial_warning_never_carries_token_material()
{
const string presented = "presented-token-that-must-not-be-logged";
SecretsHubAuthInterceptor interceptor = Build(
ConfiguredToken, out HubCapturingLogger logger, out HubFakeTimeProvider clock);
await AssertDeniedAsync(interceptor, $"Bearer {presented}");
clock.Advance(TimeSpan.FromSeconds(61));
await AssertDeniedAsync(interceptor, $"Bearer {presented}");
Assert.Equal(2, DenialWarnings(logger).Count);
foreach (string message in logger.Entries.Select(e => e.Message))
{
Assert.DoesNotContain(ConfiguredToken, message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(presented, message, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public async Task An_accepted_call_never_warns()
{
SecretsHubAuthInterceptor interceptor = Build(
ConfiguredToken, out HubCapturingLogger logger, out _);
await interceptor.UnaryServerHandler(
new GetManifestRequest(),
HubServerCallContextStub.WithAuthorization($"Bearer {ConfiguredToken}"),
(_, _) => Task.FromResult(new GetManifestResponse()));
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Warning);
}
[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 logger) =>
Build(token, out logger, out _);
private static SecretsHubAuthInterceptor Build(
string token,
out HubCapturingLogger logger,
out HubFakeTimeProvider clock)
{
logger = new HubCapturingLogger();
clock = new HubFakeTimeProvider();
var options = new SecretsGrpcHubServerOptions { BearerToken = token };
return new SecretsHubAuthInterceptor(Options.Create(options), logger, clock);
}
/// The rate-limited denial summaries only — not the unset-token configuration warning.
private static List DenialWarnings(HubCapturingLogger logger) =>
logger.Entries
.Where(e => e.Level == LogLevel.Warning
&& e.Message.Contains("The gRPC secrets hub denied", StringComparison.Ordinal))
.Select(e => e.Message)
.ToList();
private static async Task AssertDeniedAsync(
SecretsHubAuthInterceptor interceptor, string? header)
{
RpcException error = await Assert.ThrowsAsync(
() => interceptor.UnaryServerHandler(
new GetManifestRequest(),
HubServerCallContextStub.WithAuthorization(header),
(_, _) => Task.FromResult(new GetManifestResponse())));
Assert.Equal(StatusCode.Unauthenticated, error.StatusCode);
return error;
}
}
///
/// A minimal for exercising interceptors and service methods
/// directly. Hand-rolled because Grpc.Core.Testing.TestServerCallContext ships in a
/// package this project deliberately does not reference.
///
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>());
private readonly Dictionary