Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsHubAuthInterceptorTests.cs
T
Joseph Doherty c86cead5c2 fix(secrets): rate-limited denial warning on the Grpc hub (live-gate finding) — 0.4.1
The ScadaBridge live gate (check 4) found SecretsHubAuthInterceptor denying
correctly but logging nothing: the only trace of a refusal was a framework
Grpc.AspNetCore.Server Information line. A follower whose bearer token was
mis-rotated therefore stopped converging silently, with the hub showing it
only at INF.

Every denial is now counted and summarised in a Warning emitted on the first
denial after startup or after a 60-second quiet window, carrying the denials
since the previous summary plus a breakdown by cause. A single probe is
visible immediately; a sustained flood is bounded to one line per window,
which is what makes warning safe at all — the endpoint is unauthenticated by
construction, so a per-denial warning would be an unbounded log write driven
by an unauthenticated caller. Counters are Interlocked and the emit is
decided by a compare-exchange on the last-emit timestamp, so exactly one
thread emits per window and a denial counted after the drain carries into
the next summary rather than being lost.

The cause breakdown is server-side diagnostics only. Denied() is untouched:
every rejection still returns the identical uniform Unauthenticated status,
the comparison is still constant-time, and no token material appears in any
log line. The existing warn-once-on-unconfigured-token signal is unchanged
and still emitted exactly once; denials in that state count toward the new
summary's "unconfigured" category.

TimeProvider is an optional default-valued parameter rather than a second
overload — the type must keep exactly one public constructor or the gRPC
interceptor activator silently stops invoking the auth gate.

Two existing tests scoped their assertions to the configuration warning,
since both kinds now share a category: the new line is deliberate behavior,
not drift.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
2026-08-07 08:26:49 -04:00

620 lines
26 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}");
}
// 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<string> 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<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);
}
}
// ---- 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<SecretsHubAuthInterceptor> 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<SecretsHubAuthInterceptor> 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<SecretsHubAuthInterceptor> 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<string> 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<SecretsHubAuthInterceptor> 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<SecretsHubAuthInterceptor> 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<SecretsHubAuthInterceptor> 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<SecretsHubAuthInterceptor> 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<SecretsHubAuthInterceptor> 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<SecretsHubAuthInterceptor> logger) =>
Build(token, out logger, out _);
private static SecretsHubAuthInterceptor Build(
string token,
out HubCapturingLogger<SecretsHubAuthInterceptor> logger,
out HubFakeTimeProvider clock)
{
logger = new HubCapturingLogger<SecretsHubAuthInterceptor>();
clock = new HubFakeTimeProvider();
var options = new SecretsGrpcHubServerOptions { BearerToken = token };
return new SecretsHubAuthInterceptor(Options.Create(options), logger, clock);
}
/// <summary>The rate-limited denial summaries only — not the unset-token configuration warning.</summary>
private static List<string> DenialWarnings(HubCapturingLogger<SecretsHubAuthInterceptor> 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<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>
/// A manually advanced clock, so the denial-warning rate limit is exercised by moving time rather
/// than by sleeping through a 60-second window.
/// </summary>
/// <remarks>
/// Hand-rolled rather than taken from <c>Microsoft.Extensions.TimeProvider.Testing</c>, which this
/// project deliberately does not reference — only <see cref="TimeProvider.GetUtcNow"/> is needed.
/// Reads are volatile because the concurrency test drives it from many threads at once.
/// </remarks>
internal sealed class HubFakeTimeProvider : TimeProvider
{
private long _utcTicks = new DateTime(2026, 8, 7, 12, 0, 0, DateTimeKind.Utc).Ticks;
/// <inheritdoc/>
public override DateTimeOffset GetUtcNow() =>
new(Volatile.Read(ref _utcTicks), TimeSpan.Zero);
/// <summary>Moves the clock forward by <paramref name="delta"/>.</summary>
public void Advance(TimeSpan delta) => Interlocked.Add(ref _utcTicks, delta.Ticks);
}
/// <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)));
}
}