3e3748b985
Adds the end-to-end layer the unit suites structurally cannot reach: a real
in-process ASP.NET Core host serving SecretsHubService through
AddZbSecretsGrpcHub/MapZbSecretsHub, a follower built by the shipped
AddZbSecretsGrpcHubClient, a real gRPC call between them, and real SQLite
stores on both sides. The class of defect this catches is pieces that are each
individually correct and are never connected to one another — for instance the
interceptor's warn-once, which only holds if the container really hands gRPC
the singleton it registered.
Covered: byte-identical convergence over the wire (every ciphertext/nonce/tag/
wrapped-DEK byte, kek_id, revision, both timestamps INCLUDING their offsets,
across a tombstone, a nulls-everywhere row and an empty-string-description row);
delete propagation; a hub unreachable at follower boot leaving the last-known-
good store intact and converging once it answers; the auth negatives fail-closed
across the wire with one uniform denial detail; the per-request name cap as
ResourceExhausted; and an RpcException from the call invoker escaping the hub
client unchanged, so a transport fault stays distinguishable from a parse fault.
The credential-hygiene assertions are made against logs captured at Trace on
BOTH hosts, so "no token material ever reaches a sink" is asserted at the most
verbose setting a deployment could switch on.
SecretsHubContractPinTests reads the generated descriptor — not the .proto text
— and pins the RPC set to exactly {GetManifest, GetSecrets}, plus unary-only and
the package-qualified service name. The hub is pull-only by construction:
updates originate at central only, and the absence of a write RPC is what
enforces that, not authorization a mis-wired follower may hold more of than
intended. A third RPC silently retires the guarantee, so the failure message
says so.
Microsoft.AspNetCore.TestHost pinned at 10.0.7 — the version ZB.MOM.WW.LocalDb,
OtOpcUa and ScadaBridge already pin, matching the Microsoft.Extensions.* line.
Full suite: 436 passed / 0 failed / 15 skipped, no new warnings.
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
376 lines
15 KiB
C#
376 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 the pieces are wired here the way that
|
|
/// extension wires them, minus the eager validation. 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) =>
|
|
StartAsync(store, services =>
|
|
{
|
|
services.Configure<SecretsGrpcHubServerOptions>(options => options.BearerToken = string.Empty);
|
|
services.AddSingleton<SecretsHubAuthInterceptor>();
|
|
services.AddGrpc()
|
|
.AddServiceOptions<SecretsHubService>(
|
|
options => options.Interceptors.Add<SecretsHubAuthInterceptor>());
|
|
});
|
|
|
|
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);
|
|
}
|
|
}
|