using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests; /// /// Covers the hub follower's sweep-outcome observability: the Debug-per-sweep / Information- /// heartbeat-once-per-interval pair for an in-sync sweep (EventId 4/5), the recovered line the /// first success after a run of failures logs (EventId 6), and /// tracking the same outcomes. /// public sealed class GrpcSecretSyncServiceLoggingTests { [Fact] public async Task InSyncSweep_LogsDebugEveryTime_AndInformationHeartbeatOncePerInterval() { var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-09-28T00:00:00Z")); (GrpcSecretSyncService svc, RecordingListLogger logs, _) = Harness.Create( time, inSyncLogInterval: TimeSpan.FromHours(1), manifestUnchanged: true); await svc.SweepForTestAsync(CancellationToken.None); time.Advance(TimeSpan.FromMinutes(30)); await svc.SweepForTestAsync(CancellationToken.None); time.Advance(TimeSpan.FromMinutes(31)); await svc.SweepForTestAsync(CancellationToken.None); Assert.Equal(3, logs.Entries.Count(l => l.EventId.Id == 4 && l.Level == LogLevel.Debug)); Assert.Equal(2, logs.Entries.Count(l => l.EventId.Id == 5 && l.Level == LogLevel.Information)); } [Fact] public async Task FirstSuccessAfterFailures_LogsRecovered_AndStatusResets() { var time = new FakeTimeProvider(); (GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) = Harness.Create(time, failFirst: 2); await svc.SweepForTestAsync(default); await svc.SweepForTestAsync(default); Assert.Equal(2, status.ConsecutiveFailures); await svc.SweepForTestAsync(default); Assert.Contains(logs.Entries, l => l.EventId.Id == 6 && l.Message.Contains("after 2 failed")); Assert.Equal(0, status.ConsecutiveFailures); Assert.NotNull(status.LastSuccessUtc); } [Fact] public async Task A_failed_sweep_never_logs_EventId6_and_status_records_the_error() { var time = new FakeTimeProvider(); (GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) = Harness.Create(time, failFirst: 1); await svc.SweepForTestAsync(default); Assert.DoesNotContain(logs.Entries, l => l.EventId.Id == 6); Assert.Equal(1, status.ConsecutiveFailures); Assert.NotNull(status.LastError); Assert.Null(status.LastSuccessUtc); } [Fact] public async Task An_in_sync_sweep_never_logs_the_recovered_line() { var time = new FakeTimeProvider(); (GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) = Harness.Create(time, manifestUnchanged: true); await svc.SweepForTestAsync(default); await svc.SweepForTestAsync(default); Assert.DoesNotContain(logs.Entries, l => l.EventId.Id == 6); Assert.Equal(0, status.ConsecutiveFailures); Assert.NotNull(status.LastSweepUtc); } // Assembles a GrpcSecretSyncService wired to a controllable fake hub, a no-op local store (the // scenarios here never actually apply a row — an empty hub manifest means the reconciler's pull // set is always empty), a log-capturing logger, and the internal status tracker the service // writes to — so a test can both drive sweeps and observe everything they produced. private static class Harness { public static (GrpcSecretSyncService Service, RecordingListLogger Logs, ISecretsHubSyncStatus Status) Create( FakeTimeProvider time, TimeSpan? inSyncLogInterval = null, bool manifestUnchanged = false, int failFirst = 0) { var logger = new RecordingListLogger(); var status = new SecretsHubSyncStatus(); ISecretsHubReader hub = failFirst > 0 ? new SequencedFailThenEmptyHubReader(failFirst) : new EmptyHubReader(); IOptions options = Options.Create(new SecretsGrpcHubClientOptions { Endpoint = "https://central:5001", BearerToken = "unused-by-the-fake", SyncInterval = TimeSpan.FromHours(1), InSyncLogInterval = inSyncLogInterval ?? TimeSpan.FromHours(1), }); // manifestUnchanged has no separate fake to select: both readers above always answer an // empty (therefore unchanged, nothing-to-pull) manifest once they are done failing, so // the parameter exists to make each call site self-documenting rather than to branch on. var svc = new GrpcSecretSyncService( new NoOpLocalStore(), hub, cacheInvalidator: null, options, logger, time, status); return (svc, logger, status); } } /// A hub with nothing in it — every sweep is in-sync with zero rows to pull. private sealed class EmptyHubReader : ISecretsHubReader { public Task> GetManifestAsync(CancellationToken ct) => Task.FromResult>([]); public Task> GetManyAsync( IReadOnlyList names, CancellationToken ct) => Task.FromResult>([]); } /// /// Fails the manifest call with a transport-shaped exception for the first /// calls, then behaves exactly like /// forever after. /// private sealed class SequencedFailThenEmptyHubReader(int failuresBeforeSuccess) : ISecretsHubReader { private int _remaining = failuresBeforeSuccess; public Task> GetManifestAsync(CancellationToken ct) { if (_remaining > 0) { _remaining--; return Task.FromException>( new InvalidOperationException("hub unreachable")); } return Task.FromResult>([]); } public Task> GetManyAsync( IReadOnlyList names, CancellationToken ct) => Task.FromResult>([]); } /// /// A local store that is never actually exercised by these scenarios (the hub manifest is /// always empty, so the reconciler's pull set is always empty and none of these members are /// called) beyond , which the reconciler always calls to compute /// the pull set. /// private sealed class NoOpLocalStore : ISecretStore { public Task GetAsync(SecretName name, CancellationToken ct) => throw new NotSupportedException("not exercised by the logging test scenarios."); public Task UpsertAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException("not exercised by the logging test scenarios."); public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct) => throw new NotSupportedException("not exercised by the logging test scenarios."); public Task> ListAsync(bool includeDeleted, CancellationToken ct) => throw new NotSupportedException("not exercised by the logging test scenarios."); public Task> GetManifestAsync(CancellationToken ct) => Task.FromResult>([]); public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException("not exercised by the logging test scenarios."); public Task ApplyRewrapAsync( StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => throw new NotSupportedException("not exercised by the logging test scenarios."); } } /// A clock a test advances by hand, so an hourly heartbeat can be tested without waiting an hour. /// /// Hand-rolled rather than taken from Microsoft.Extensions.TimeProvider.Testing, matching /// this project's existing convention (see HubFakeTimeProvider in /// SecretsHubAuthInterceptorTests.cs) of not taking that package dependency for one small type. /// internal sealed class FakeTimeProvider : TimeProvider { private DateTimeOffset _now; /// Creates a clock starting at the current wall-clock time. public FakeTimeProvider() : this(DateTimeOffset.UtcNow) { } /// Creates a clock starting at . /// The instant the clock starts at. public FakeTimeProvider(DateTimeOffset now) => _now = now; /// public override DateTimeOffset GetUtcNow() => _now; /// Moves the clock forward by . /// The amount of time to advance. public void Advance(TimeSpan delta) => _now += delta; } /// Captures every log entry's level, event id, and formatted message, across all levels. /// /// Distinct from the existing RecordingLogger<T> in TestDoubles.cs, which only /// captures Information/Warning and neither type captures — this suite needs /// Debug-level entries and event-id assertions that the existing double does not support, and /// widening it would risk the tests that already depend on its narrower shape. /// internal sealed class RecordingListLogger : ILogger { /// Every entry logged, in order. public List<(LogLevel Level, EventId EventId, string Message)> Entries { get; } = []; /// 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); Entries.Add((logLevel, eventId, formatter(state, exception))); } }