5f81e65684
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
377 lines
15 KiB
C#
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);
|
|
}
|
|
}
|