using System.Net; using System.Net.Sockets; using Grpc.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection; namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests; /// /// Secrets-019: cancelling a hub call (host shutdown) must surface as cancellation — not as /// (Cancelled), which the sweep recorded as a failed sync and the failover /// reader answered by dialing every other endpoint with the same cancelled token. /// public sealed class HubCancellationTests { private static RpcException Cancelled() => new(new Status(StatusCode.Cancelled, "Call canceled by the client.")); [Fact] public async Task The_registered_channel_reports_a_cancelled_call_as_OperationCanceledException() { // A hub that accepts the connection and never answers: the call can only end by cancellation. using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); int port = ((IPEndPoint)listener.LocalEndpoint).Port; var held = new List(); _ = Task.Run(async () => { try { while (true) { held.Add(await listener.AcceptTcpClientAsync()); } } catch (Exception) { /* listener stopped */ } }); IConfiguration configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Secrets:GrpcHub:Endpoint"] = $"http://127.0.0.1:{port}", ["Secrets:GrpcHub:BearerToken"] = "token-for-a-silent-hub", ["Secrets:GrpcHub:SyncInterval"] = "01:00:00", ["Secrets:GrpcHub:SyncOnStartup"] = "false", }) .Build(); var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(new NullStore()); services.AddZbSecretsGrpcHubClient(configuration); await using ServiceProvider sp = services.BuildServiceProvider(); ISecretsHubReader hub = sp.GetRequiredService(); using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); Exception ex = await Record.ExceptionAsync(() => hub.GetManifestAsync(cts.Token)); Assert.IsAssignableFrom(ex); listener.Stop(); held.ForEach(c => c.Dispose()); } [Fact] public async Task The_failover_reader_does_not_rotate_a_cancelled_call_through_the_fallbacks() { var primary = new FaultingReader(Cancelled); var fallback = new FaultingReader(Cancelled); var logger = new RecordingLogger(); var reader = new FailoverSecretsHubReader([primary, fallback], ["https://a:5001", "https://b:5001"], logger); using var cts = new CancellationTokenSource(); cts.Cancel(); await Assert.ThrowsAnyAsync(() => reader.GetManifestAsync(cts.Token)); Assert.Equal(1, primary.Calls); Assert.Equal(0, fallback.Calls); Assert.Equal(0, logger.WarningCount); } [Fact] public async Task A_cancelled_status_without_a_cancelled_token_still_fails_over() { // Positive control: Cancelled from the SERVER (token not cancelled) is an endpoint failure. var primary = new FaultingReader(Cancelled); var fallback = new FaultingReader(fault: null); var reader = new FailoverSecretsHubReader( [primary, fallback], ["https://a:5001", "https://b:5001"], new RecordingLogger()); await reader.GetManifestAsync(CancellationToken.None); Assert.Equal(1, fallback.Calls); } [Fact] public async Task A_sweep_cancelled_gRPC_style_at_shutdown_is_not_recorded_as_a_failure() { var status = new SecretsHubSyncStatus(); var logger = new RecordingLogger(); var svc = new GrpcSecretSyncService( new NullStore(), new FaultingReader(Cancelled), cacheInvalidator: null, Options.Create(new SecretsGrpcHubClientOptions { Endpoint = "https://central:5001", BearerToken = "unused", SyncInterval = TimeSpan.FromHours(1), }), logger, TimeProvider.System, status); using var cts = new CancellationTokenSource(); cts.Cancel(); await svc.SweepForTestAsync(cts.Token); Assert.Equal(0, status.ConsecutiveFailures); Assert.Null(status.LastError); Assert.Equal(0, logger.WarningCount); } private sealed class FaultingReader(Func? fault) : ISecretsHubReader { public int Calls; public Task> GetManifestAsync(CancellationToken ct) { Interlocked.Increment(ref Calls); return fault is null ? Task.FromResult>([]) : Task.FromException>(fault()); } public Task> GetManyAsync(IReadOnlyList names, CancellationToken ct) => Task.FromResult>([]); } private sealed class NullStore : ISecretStore { public Task GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(null); public Task UpsertAsync(StoredSecret row, CancellationToken ct) => Task.CompletedTask; public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct) => Task.FromResult(false); public Task> ListAsync(bool includeDeleted, CancellationToken ct) => Task.FromResult>([]); public Task> GetManifestAsync(CancellationToken ct) => Task.FromResult>([]); public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => Task.CompletedTask; public Task ApplyRewrapAsync(StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => Task.FromResult(false); } }