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 _userState = new(); private HubServerCallContextStub(Metadata requestHeaders, CancellationToken cancellationToken) { _requestHeaders = requestHeaders; _cancellationToken = cancellationToken; } /// Builds a context carrying the given Authorization header, or none when null. 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); } /// Builds a context with no metadata at all, for service-level tests. public static HubServerCallContextStub Empty() => new(new Metadata(), CancellationToken.None); /// Builds a context whose call token is the given one, for propagation tests. 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 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; } /// A no-op response stream, enough to drive the server-streaming interceptor path. internal sealed class HubServerStreamWriterStub : IServerStreamWriter { /// Everything written, in order. public List Written { get; } = []; /// public WriteOptions? WriteOptions { get; set; } /// public Task WriteAsync(T message) { Written.Add(message); return Task.CompletedTask; } } /// An empty request stream, enough to drive the two client-reading interceptor paths. internal sealed class HubAsyncStreamReaderStub : IAsyncStreamReader where T : class { /// public T Current => throw new InvalidOperationException("The stub stream carries no messages."); /// public Task MoveNext(CancellationToken cancellationToken) => Task.FromResult(false); } /// /// A manually advanced clock, so the denial-warning rate limit is exercised by moving time rather /// than by sleeping through a 60-second window. /// /// /// Hand-rolled rather than taken from Microsoft.Extensions.TimeProvider.Testing, which this /// project deliberately does not reference — only is needed. /// Reads are volatile because the concurrency test drives it from many threads at once. /// internal sealed class HubFakeTimeProvider : TimeProvider { private long _utcTicks = new DateTime(2026, 8, 7, 12, 0, 0, DateTimeKind.Utc).Ticks; /// public override DateTimeOffset GetUtcNow() => new(Volatile.Read(ref _utcTicks), TimeSpan.Zero); /// Moves the clock forward by . public void Advance(TimeSpan delta) => Interlocked.Add(ref _utcTicks, delta.Ticks); } /// Captures formatted log messages so tests can assert on what was — and was not — logged. internal sealed class HubCapturingLogger : ILogger { /// Every entry logged, formatted exactly as a sink would render it. public List<(LogLevel Level, string Message)> Entries { get; } = []; /// public IDisposable? BeginScope(TState state) where TState : notnull => null; /// public bool IsEnabled(LogLevel logLevel) => true; /// public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { ArgumentNullException.ThrowIfNull(formatter); Entries.Add((logLevel, formatter(state, exception))); } }