Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsHubAuthInterceptorTests.cs
T
Joseph Doherty 7fc77f2790 feat(secrets): Grpc hub server — SecretsHubService + fail-closed bearer interceptor + DI
The server half of the central-hosted pull-only secrets hub. SecretsHubService
adapts the node's own ISecretStore onto secrets_hub.v1 using exactly two read
members (GetManifestAsync, GetAsync) — the proto exposes no write RPC, so a
follower has no path to put a row into central whatever its credentials allow.

GetSecrets omits a name the hub does not hold and skips one that will not parse
as a SecretName; neither is an error, because both describe a follower acting on
a manifest that was true a moment ago. Failing the call would stall the sweep and
let one bad name deny the follower every other row in the batch. Names are
re-validated server-side (the wire type is a plain string) and de-duplicated on
the normalized value.

DomainToWire mirrors the Akka replicator's SecretRowDto discipline exactly:
round-trip "O" timestamps so a DateTimeOffset's offset and ticks survive the
last-writer-wins equality compare, the content type as its enum MEMBER NAME so an
unknown value is rejected rather than decoded to the zero member, and nullable
fields left absent rather than written as "" so null and empty stay distinct.

SecretsHubAuthInterceptor fails closed: with no token configured every call is
denied and one warning names the exact configuration key — never a token value,
presented or configured, at any level. Denial is uniform (one Status, one detail,
no "why") so a caller gets no oracle. Comparison is constant-time over UTF-8
bytes. All four handler shapes are gated, not just unary, so a future RPC cannot
arrive unauthenticated by omission; more than one Authorization header is refused
as a smuggling shape. The type keeps EXACTLY ONE public constructor — Grpc's
activator silently stops invoking an interceptor it cannot construct
unambiguously, and the failure mode is an auth gate that quietly does not run — so
a test pins the count.

Tests: 50, red-first (compile-red, then a fail-closed mutation verified 5 of them
bite). Full Secrets suite 328 passed / 0 failed / 15 live-gated skips, 0 new
warnings.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 06:09:23 -04:00

321 lines
13 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 Non_unary_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 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 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) => _requestHeaders = requestHeaders;
/// <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);
}
/// <summary>Builds a context with no metadata at all, for service-level tests.</summary>
public static HubServerCallContextStub Empty() => new(new Metadata());
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.None;
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>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)));
}
}