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);
}
}