fac3c5355c
Secrets-019: the hub channels are created with ThrowOperationCanceledOnCancellation = true, so a call cancelled through its token throws OperationCanceledException. Defensively, the failover reader rethrows RpcException(Cancelled)-with-a-cancelled-token as cancellation instead of dialing every fallback with the same cancelled token, and the sweep treats it as shutdown, not a failure recorded in ISecretsHubSyncStatus. A server-side Cancelled status (token not cancelled) still fails over. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
149 lines
6.4 KiB
C#
149 lines
6.4 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Secrets-019: cancelling a hub call (host shutdown) must surface as cancellation — not as
|
|
/// <see cref="RpcException"/>(Cancelled), which the sweep recorded as a failed sync and the failover
|
|
/// reader answered by dialing every other endpoint with the same cancelled token.
|
|
/// </summary>
|
|
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<TcpClient>();
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try { while (true) { held.Add(await listener.AcceptTcpClientAsync()); } }
|
|
catch (Exception) { /* listener stopped */ }
|
|
});
|
|
|
|
IConfiguration configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["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<ISecretStore>(new NullStore());
|
|
services.AddZbSecretsGrpcHubClient(configuration);
|
|
await using ServiceProvider sp = services.BuildServiceProvider();
|
|
ISecretsHubReader hub = sp.GetRequiredService<ISecretsHubReader>();
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
|
Exception ex = await Record.ExceptionAsync(() => hub.GetManifestAsync(cts.Token));
|
|
|
|
Assert.IsAssignableFrom<OperationCanceledException>(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<FailoverSecretsHubReader>();
|
|
var reader = new FailoverSecretsHubReader([primary, fallback], ["https://a:5001", "https://b:5001"], logger);
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 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<FailoverSecretsHubReader>());
|
|
|
|
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<GrpcSecretSyncService>();
|
|
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<Exception>? fault) : ISecretsHubReader
|
|
{
|
|
public int Calls;
|
|
|
|
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct)
|
|
{
|
|
Interlocked.Increment(ref Calls);
|
|
return fault is null
|
|
? Task.FromResult<IReadOnlyList<SecretManifestEntry>>([])
|
|
: Task.FromException<IReadOnlyList<SecretManifestEntry>>(fault());
|
|
}
|
|
|
|
public Task<IReadOnlyList<StoredSecret>> GetManyAsync(IReadOnlyList<SecretName> names, CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<StoredSecret>>([]);
|
|
}
|
|
|
|
private sealed class NullStore : ISecretStore
|
|
{
|
|
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<StoredSecret?>(null);
|
|
public Task UpsertAsync(StoredSecret row, CancellationToken ct) => Task.CompletedTask;
|
|
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) => Task.FromResult(false);
|
|
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<SecretMetadata>>([]);
|
|
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
|
|
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => Task.CompletedTask;
|
|
public Task<bool> ApplyRewrapAsync(StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
|
|
Task.FromResult(false);
|
|
}
|
|
}
|