Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.Grpc.Tests/SecretsHubTestHarness.cs
T
Joseph Doherty 5f81e65684 fix(secrets): route the unset-token harness through the real hub composition (review drift-risk)
An internal validateEagerly seam on AddZbSecretsGrpcHub replaces the harness's
hand-copied registration fragment, so the deny-all second-layer tests exercise
the production wiring and cannot drift from it.

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

377 lines
15 KiB
C#

using System.Collections.Concurrent;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
/// <summary>One captured log entry: enough to assert what was said and to prove what was not.</summary>
/// <param name="Category">The logger category the entry was written to.</param>
/// <param name="Level">The level it was written at.</param>
/// <param name="Message">The formatted message.</param>
/// <param name="Exception">The exception's full text, or the empty string when there was none.</param>
internal sealed record CapturedLog(string Category, LogLevel Level, string Message, string Exception)
{
/// <summary>Message and exception text together — everything this entry could leak.</summary>
public string AllText => Message + "\n" + Exception;
}
/// <summary>
/// An <see cref="ILoggerProvider"/> that records every entry written anywhere in a host, so a test
/// can assert both what was logged and — the security half — what never appeared in any log line.
/// </summary>
/// <remarks>
/// Registered at <see cref="LogLevel.Trace"/> deliberately: an assertion that no bearer token ever
/// reaches a sink is only worth making against the most verbose setting a deployment could enable.
/// </remarks>
internal sealed class CapturingLoggerProvider : ILoggerProvider
{
private readonly ConcurrentQueue<CapturedLog> _entries = new();
/// <summary>Everything logged so far, in order.</summary>
public IReadOnlyList<CapturedLog> Entries => [.. _entries];
/// <summary>Entries at <see cref="LogLevel.Warning"/> written by <typeparamref name="T"/>.</summary>
/// <typeparam name="T">The logging type whose category is matched.</typeparam>
/// <returns>The matching warnings, in order.</returns>
public IReadOnlyList<CapturedLog> WarningsFrom<T>() =>
[.. _entries.Where(entry =>
entry.Level == LogLevel.Warning &&
string.Equals(entry.Category, typeof(T).FullName, StringComparison.Ordinal))];
/// <inheritdoc />
public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, _entries);
/// <inheritdoc />
public void Dispose()
{
// Nothing to release, and the entries deliberately outlive the host: assertions run after
// the host that produced them has been torn down.
}
private sealed class CapturingLogger(string category, ConcurrentQueue<CapturedLog> sink) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
ArgumentNullException.ThrowIfNull(formatter);
sink.Enqueue(new CapturedLog(
category,
logLevel,
formatter(state, exception),
exception?.ToString() ?? string.Empty));
}
}
}
/// <summary>
/// An <see cref="HttpMessageHandler"/> whose backing transport can be swapped at runtime, including
/// to "nothing at all".
/// </summary>
/// <remarks>
/// This is how the hub-outage scenario is expressed without a real socket: with no inner handler the
/// send fails the way a refused connection does, and installing one afterwards models central coming
/// back. The channel, the generated client and the sweep are all the real ones and never learn that
/// anything changed — which is the point, since the behaviour under test is that the follower keeps
/// serving its last-known-good store and converges once the hub answers again.
/// </remarks>
internal sealed class SwitchableHttpHandler : HttpMessageHandler
{
private volatile HttpMessageInvoker? _inner;
/// <summary>Points the handler at a live transport.</summary>
/// <param name="handler">The handler to forward to.</param>
public void ConnectTo(HttpMessageHandler handler) => _inner = new HttpMessageInvoker(handler);
/// <summary>Takes the transport away, so every send fails as an unreachable host does.</summary>
public void Disconnect() => _inner = null;
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
HttpMessageInvoker? inner = _inner;
return inner is null
? throw new HttpRequestException("The secrets hub refused the connection.")
: inner.SendAsync(request, cancellationToken);
}
}
/// <summary>A real SQLite-backed <see cref="ISecretStore"/> in a temp file, deleted on dispose.</summary>
/// <remarks>
/// Real store rather than a dictionary, for the same reason the offline sweep suite uses one: the
/// behaviour under test is last-writer-wins convergence, and a hand-rolled fake would re-implement
/// the very logic the assertions exist to check.
/// </remarks>
internal sealed class TempSecretStore : IDisposable
{
private readonly string _path;
/// <summary>Creates and migrates a fresh store.</summary>
/// <param name="label">A short label folded into the file name to aid debugging.</param>
public TempSecretStore(string label)
{
_path = Path.Combine(Path.GetTempPath(), $"zb-grpc-e2e-{label}-{Guid.NewGuid():N}.db");
var factory = new SecretsSqliteConnectionFactory(_path);
new SqliteSecretsStoreMigrator(factory).MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
Store = new SqliteSecretStore(factory);
}
/// <summary>The store.</summary>
public SqliteSecretStore Store { get; }
/// <inheritdoc />
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
foreach (string path in new[] { _path, _path + "-wal", _path + "-shm" })
{
if (File.Exists(path))
{
File.Delete(path);
}
}
}
}
/// <summary>
/// The central node: a real in-process ASP.NET Core host serving <see cref="SecretsHubService"/>
/// through <c>AddZbSecretsGrpcHub</c> + <c>MapZbSecretsHub</c>, over
/// <see cref="Microsoft.AspNetCore.TestHost.TestServer"/>.
/// </summary>
/// <remarks>
/// Everything the production composition root does happens here — options binding from
/// configuration, the fail-closed interceptor registered as a singleton and attached to the service,
/// routing, endpoint mapping — so a defect in the wiring shows up as a failing call rather than as a
/// unit test that constructed the pieces by hand and never noticed they were never connected.
/// </remarks>
internal sealed class SecretsHubTestServer : IAsyncDisposable
{
private readonly IHost _host;
private SecretsHubTestServer(IHost host, CapturingLoggerProvider logs)
{
_host = host;
Logs = logs;
}
/// <summary>Everything the hub host logged.</summary>
public CapturingLoggerProvider Logs { get; }
/// <summary>A handler that dials this server in process.</summary>
/// <returns>The handler.</returns>
public HttpMessageHandler CreateHandler() => _host.GetTestServer().CreateHandler();
/// <summary>
/// Starts a hub registered exactly as a central node registers it.
/// </summary>
/// <param name="store">The store the hub serves.</param>
/// <param name="bearerToken">The shared bearer token followers must present.</param>
/// <param name="maxNamesPerRequest">Optional per-request name cap.</param>
/// <returns>The running hub.</returns>
public static Task<SecretsHubTestServer> StartAsync(
ISecretStore store, string bearerToken, int? maxNamesPerRequest = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bearerToken);
var settings = new Dictionary<string, string?>
{
["Secrets:GrpcHub:BearerToken"] = bearerToken,
};
if (maxNamesPerRequest is int cap)
{
settings["Secrets:GrpcHub:MaxNamesPerRequest"] =
cap.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(settings)
.Build();
return StartAsync(store, services => services.AddZbSecretsGrpcHub(configuration));
}
/// <summary>
/// Starts a hub whose options carry <b>no</b> bearer token.
/// </summary>
/// <remarks>
/// <c>AddZbSecretsGrpcHub</c> refuses this configuration at registration — which is the intended
/// first line of defence and is pinned elsewhere — so this path goes through the REAL composition
/// via its internal <c>validateEagerly: false</c> seam, changing nothing else. That is precisely
/// the case the interceptor's deny-all exists for: a host that built the options some other way.
/// Without this path the second layer could only ever be tested against a hand-constructed
/// interceptor, never over the wire.
/// </remarks>
/// <param name="store">The store the hub would serve.</param>
/// <returns>The running hub.</returns>
public static Task<SecretsHubTestServer> StartWithNoTokenConfiguredAsync(ISecretStore store)
{
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
return StartAsync(store, services =>
SecretsGrpcHubServerExtensions.AddZbSecretsGrpcHubCore(
services, configuration, "Secrets:GrpcHub", validateEagerly: false));
}
private static async Task<SecretsHubTestServer> StartAsync(
ISecretStore store, Action<IServiceCollection> registerHub)
{
var logs = new CapturingLoggerProvider();
IHost host = await new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(services =>
{
services.AddLogging(builder =>
{
builder.SetMinimumLevel(LogLevel.Trace);
builder.AddProvider(logs);
});
services.AddRouting();
services.AddSingleton(store);
registerHub(services);
});
web.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapZbSecretsHub());
});
})
.StartAsync();
return new SecretsHubTestServer(host, logs);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
try
{
await _host.StopAsync(TimeSpan.FromSeconds(5));
}
catch (Exception)
{
// Teardown only: a host that has already faulted must not mask the assertion that ran
// before it.
}
_host.Dispose();
}
}
/// <summary>
/// The site node: the package's own client registration, dialling the in-process hub.
/// </summary>
/// <remarks>
/// <c>AddZbSecretsGrpcHubClient</c> is called for real; the only substitution is a keyed
/// <see cref="GrpcChannel"/> registered <b>before</b> it, which the extension's
/// <c>TryAddKeyedSingleton</c> then leaves standing. So the generated client, the reader seam, the
/// bearer header, the per-call deadline and the hosted sweep are all the shipped ones.
/// </remarks>
internal sealed class SecretsHubTestClient : IAsyncDisposable
{
private readonly ServiceProvider _provider;
private SecretsHubTestClient(
ServiceProvider provider, CapturingLoggerProvider logs, List<SecretName> invalidated)
{
_provider = provider;
Logs = logs;
Invalidated = invalidated;
Sweeper = provider.GetServices<IHostedService>().OfType<GrpcSecretSyncService>().Single();
Hub = provider.GetRequiredService<ISecretsHubReader>();
}
/// <summary>Everything the follower logged.</summary>
public CapturingLoggerProvider Logs { get; }
/// <summary>The shipped pull-only sweep, resolved as a hosted service.</summary>
public GrpcSecretSyncService Sweeper { get; }
/// <summary>The shipped hub reader, for the calls a sweep does not make on its own.</summary>
public ISecretsHubReader Hub { get; }
/// <summary>Names the sweep evicted from the resolver cache.</summary>
public List<SecretName> Invalidated { get; }
/// <summary>Builds a follower over the supplied transport.</summary>
/// <param name="local">The node's local store.</param>
/// <param name="handler">The transport reaching the hub.</param>
/// <param name="bearerToken">The credential this node presents.</param>
/// <param name="syncOnStartup">Whether the hosted sweep runs immediately on start.</param>
/// <returns>The follower.</returns>
public static SecretsHubTestClient Create(
ISecretStore local,
HttpMessageHandler handler,
string bearerToken,
bool syncOnStartup = true)
{
var logs = new CapturingLoggerProvider();
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Secrets:GrpcHub:Endpoint"] = "http://central.test",
["Secrets:GrpcHub:BearerToken"] = bearerToken,
// Long enough that the only sweeps in a test are the ones the test asks for.
["Secrets:GrpcHub:SyncInterval"] = "01:00:00",
["Secrets:GrpcHub:SyncOnStartup"] = syncOnStartup ? "true" : "false",
})
.Build();
var services = new ServiceCollection();
services.AddLogging(builder =>
{
builder.SetMinimumLevel(LogLevel.Trace);
builder.AddProvider(logs);
});
services.AddSingleton(local);
List<SecretName> invalidated = [];
services.AddSingleton<ISecretCacheInvalidator>(new DelegatingCacheInvalidator(invalidated.Add));
// Registered ahead of the extension so its TryAddKeyedSingleton is a no-op: the endpoint in
// configuration is never dialled, this in-process transport is.
services.AddKeyedSingleton(
SecretsGrpcHubClientExtensions.ChannelServiceKey,
(_, _) => GrpcChannel.ForAddress(
"http://central.test", new GrpcChannelOptions { HttpHandler = handler }));
services.AddZbSecretsGrpcHubClient(configuration);
return new SecretsHubTestClient(services.BuildServiceProvider(), logs, invalidated);
}
/// <inheritdoc />
public ValueTask DisposeAsync() => _provider.DisposeAsync();
private sealed class DelegatingCacheInvalidator(Action<SecretName> onInvalidate) : ISecretCacheInvalidator
{
public void Invalidate(SecretName name) => onInvalidate(name);
}
}