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; /// One captured log entry: enough to assert what was said and to prove what was not. /// The logger category the entry was written to. /// The level it was written at. /// The formatted message. /// The exception's full text, or the empty string when there was none. internal sealed record CapturedLog(string Category, LogLevel Level, string Message, string Exception) { /// Message and exception text together — everything this entry could leak. public string AllText => Message + "\n" + Exception; } /// /// An 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. /// /// /// Registered at deliberately: an assertion that no bearer token ever /// reaches a sink is only worth making against the most verbose setting a deployment could enable. /// internal sealed class CapturingLoggerProvider : ILoggerProvider { private readonly ConcurrentQueue _entries = new(); /// Everything logged so far, in order. public IReadOnlyList Entries => [.. _entries]; /// Entries at written by . /// The logging type whose category is matched. /// The matching warnings, in order. public IReadOnlyList WarningsFrom() => [.. _entries.Where(entry => entry.Level == LogLevel.Warning && string.Equals(entry.Category, typeof(T).FullName, StringComparison.Ordinal))]; /// public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, _entries); /// 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 sink) : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { ArgumentNullException.ThrowIfNull(formatter); sink.Enqueue(new CapturedLog( category, logLevel, formatter(state, exception), exception?.ToString() ?? string.Empty)); } } } /// /// An whose backing transport can be swapped at runtime, including /// to "nothing at all". /// /// /// 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. /// internal sealed class SwitchableHttpHandler : HttpMessageHandler { private volatile HttpMessageInvoker? _inner; /// Points the handler at a live transport. /// The handler to forward to. public void ConnectTo(HttpMessageHandler handler) => _inner = new HttpMessageInvoker(handler); /// Takes the transport away, so every send fails as an unreachable host does. public void Disconnect() => _inner = null; /// protected override Task 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); } } /// A real SQLite-backed in a temp file, deleted on dispose. /// /// 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. /// internal sealed class TempSecretStore : IDisposable { private readonly string _path; /// Creates and migrates a fresh store. /// A short label folded into the file name to aid debugging. 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); } /// The store. public SqliteSecretStore Store { get; } /// 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); } } } } /// /// The central node: a real in-process ASP.NET Core host serving /// through AddZbSecretsGrpcHub + MapZbSecretsHub, over /// . /// /// /// 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. /// internal sealed class SecretsHubTestServer : IAsyncDisposable { private readonly IHost _host; private SecretsHubTestServer(IHost host, CapturingLoggerProvider logs) { _host = host; Logs = logs; } /// Everything the hub host logged. public CapturingLoggerProvider Logs { get; } /// A handler that dials this server in process. /// The handler. public HttpMessageHandler CreateHandler() => _host.GetTestServer().CreateHandler(); /// /// Starts a hub registered exactly as a central node registers it. /// /// The store the hub serves. /// The shared bearer token followers must present. /// Optional per-request name cap. /// The running hub. public static Task StartAsync( ISecretStore store, string bearerToken, int? maxNamesPerRequest = null) { ArgumentException.ThrowIfNullOrWhiteSpace(bearerToken); var settings = new Dictionary { ["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)); } /// /// Starts a hub whose options carry no bearer token. /// /// /// AddZbSecretsGrpcHub 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 validateEagerly: false 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. /// /// The store the hub would serve. /// The running hub. public static Task StartWithNoTokenConfiguredAsync(ISecretStore store) { IConfiguration configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary()) .Build(); return StartAsync(store, services => SecretsGrpcHubServerExtensions.AddZbSecretsGrpcHubCore( services, configuration, "Secrets:GrpcHub", validateEagerly: false)); } private static async Task StartAsync( ISecretStore store, Action 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); } /// 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(); } } /// /// The site node: the package's own client registration, dialling the in-process hub. /// /// /// AddZbSecretsGrpcHubClient is called for real; the only substitution is a keyed /// registered before it, which the extension's /// TryAddKeyedSingleton 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. /// internal sealed class SecretsHubTestClient : IAsyncDisposable { private readonly ServiceProvider _provider; private SecretsHubTestClient( ServiceProvider provider, CapturingLoggerProvider logs, List invalidated) { _provider = provider; Logs = logs; Invalidated = invalidated; Sweeper = provider.GetServices().OfType().Single(); Hub = provider.GetRequiredService(); } /// Everything the follower logged. public CapturingLoggerProvider Logs { get; } /// The shipped pull-only sweep, resolved as a hosted service. public GrpcSecretSyncService Sweeper { get; } /// The shipped hub reader, for the calls a sweep does not make on its own. public ISecretsHubReader Hub { get; } /// Names the sweep evicted from the resolver cache. public List Invalidated { get; } /// Builds a follower over the supplied transport. /// The node's local store. /// The transport reaching the hub. /// The credential this node presents. /// Whether the hosted sweep runs immediately on start. /// The follower. public static SecretsHubTestClient Create( ISecretStore local, HttpMessageHandler handler, string bearerToken, bool syncOnStartup = true) { var logs = new CapturingLoggerProvider(); IConfiguration configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["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 invalidated = []; services.AddSingleton(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); } /// public ValueTask DisposeAsync() => _provider.DisposeAsync(); private sealed class DelegatingCacheInvalidator(Action onInvalidate) : ISecretCacheInvalidator { public void Invalidate(SecretName name) => onInvalidate(name); } }