using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Polly.Timeout; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; [Trait("Category", "Unit")] public sealed class ResilientConfigReaderTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-reader-{Guid.NewGuid():N}"); public void Dispose() { try { if (!Directory.Exists(_root)) return; foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories)) File.SetAttributes(f, FileAttributes.Normal); Directory.Delete(_root, recursive: true); } catch { /* best-effort */ } } [Fact] public async Task CentralDbSucceeds_ReturnsValue_MarksFresh() { var cache = new GenerationSealedCache(_root); var flag = new StaleConfigFlag { }; flag.MarkStale(); // pre-existing stale state var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance); var result = await reader.ReadAsync( "cluster-a", _ => ValueTask.FromResult("fresh-from-db"), _ => "from-cache", CancellationToken.None); result.ShouldBe("fresh-from-db"); flag.IsStale.ShouldBeFalse("successful central-DB read clears stale flag"); } [Fact] public async Task CentralDbFails_ExhaustsRetries_FallsBackToCache_MarksStale() { var cache = new GenerationSealedCache(_root); await cache.SealAsync(new GenerationSnapshot { ClusterId = "cluster-a", GenerationId = 99, CachedAt = DateTime.UtcNow, PayloadJson = "{\"cached\":true}", }); var flag = new StaleConfigFlag(); var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, timeout: TimeSpan.FromSeconds(10), retryCount: 2); var attempts = 0; var result = await reader.ReadAsync( "cluster-a", _ => { attempts++; throw new InvalidOperationException("SQL dead"); #pragma warning disable CS0162 return ValueTask.FromResult("never"); #pragma warning restore CS0162 }, snap => snap.PayloadJson, CancellationToken.None); attempts.ShouldBe(3, "1 initial + 2 retries = 3 attempts"); result.ShouldBe("{\"cached\":true}"); flag.IsStale.ShouldBeTrue("cache fallback flips stale flag true"); } [Fact] public async Task CentralDbFails_AndCacheAlsoUnavailable_Throws() { var cache = new GenerationSealedCache(_root); var flag = new StaleConfigFlag(); var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, timeout: TimeSpan.FromSeconds(10), retryCount: 0); await Should.ThrowAsync(async () => { await reader.ReadAsync( "cluster-a", _ => throw new InvalidOperationException("SQL dead"), _ => "never", CancellationToken.None); }); flag.IsStale.ShouldBeFalse("no snapshot ever served, so flag stays whatever it was"); } [Fact] public async Task Cancellation_NotRetried() { var cache = new GenerationSealedCache(_root); var flag = new StaleConfigFlag(); var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, timeout: TimeSpan.FromSeconds(10), retryCount: 5); using var cts = new CancellationTokenSource(); cts.Cancel(); var attempts = 0; await Should.ThrowAsync(async () => { await reader.ReadAsync( "cluster-a", ct => { attempts++; ct.ThrowIfCancellationRequested(); return ValueTask.FromResult("ok"); }, _ => "cache", cts.Token); }); attempts.ShouldBeLessThanOrEqualTo(1); } // ------------------------------------------------------------------------------------ // Configuration-006 — command-timeout TaskCanceledException and TimeoutRejectedException // must fall back to the sealed cache, not propagate as caller cancellation. // ------------------------------------------------------------------------------------ [Fact] public async Task CommandTimeout_TaskCanceledException_FallsBackToCache() { // A SQL command-level timeout surfaces as a TaskCanceledException thrown by the // delegate itself (not triggered by the caller's CancellationToken). It must be // treated as a transient failure and trigger the cache fallback, not be mistaken // for genuine caller cancellation and propagated. var cache = new GenerationSealedCache(_root); await cache.SealAsync(new GenerationSnapshot { ClusterId = "cluster-b", GenerationId = 7, CachedAt = DateTime.UtcNow, PayloadJson = "{\"from\":\"cache\"}", }); var flag = new StaleConfigFlag(); var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, timeout: TimeSpan.FromSeconds(10), retryCount: 0); // Simulate a command-level timeout: TaskCanceledException with no linked token. var result = await reader.ReadAsync( "cluster-b", _ => throw new TaskCanceledException("SQL command timeout (no caller token)"), snap => snap.PayloadJson, CancellationToken.None); // caller token is NOT cancelled result.ShouldBe("{\"from\":\"cache\"}", "command-timeout TaskCanceledException must fall back to sealed cache"); flag.IsStale.ShouldBeTrue("cache fallback marks the stale flag"); } [Fact] public async Task PollyTimeout_TimeoutRejectedException_FallsBackToCache() { // When Polly's own timeout strategy fires it throws TimeoutRejectedException. // That should trigger the cache fallback just like any other transient error. var cache = new GenerationSealedCache(_root); await cache.SealAsync(new GenerationSnapshot { ClusterId = "cluster-c", GenerationId = 8, CachedAt = DateTime.UtcNow, PayloadJson = "{\"from\":\"polly-timeout-cache\"}", }); var flag = new StaleConfigFlag(); // Set an extremely short Polly timeout so the async delay triggers it. var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, timeout: TimeSpan.FromMilliseconds(10), retryCount: 0); var result = await reader.ReadAsync( "cluster-c", async ct => { await Task.Delay(TimeSpan.FromSeconds(5), ct); // far exceeds 10 ms timeout return "never"; }, snap => snap.PayloadJson, CancellationToken.None); result.ShouldBe("{\"from\":\"polly-timeout-cache\"}", "Polly TimeoutRejectedException must fall back to sealed cache"); flag.IsStale.ShouldBeTrue("cache fallback marks the stale flag"); } // ------------------------------------------------------------------------------------ // Configuration-010 — fallback warning log must scrub connection-string fragments and // must not include the full exception object (which carries the stack and any inner- // exception chain). Project rule: no credential or connection-string fragment in logs. // ------------------------------------------------------------------------------------ [Fact] public async Task FallbackWarning_does_not_log_full_exception_object_or_password_fragment() { var cache = new GenerationSealedCache(_root); await cache.SealAsync(new GenerationSnapshot { ClusterId = "cluster-e", GenerationId = 1, CachedAt = DateTime.UtcNow, PayloadJson = "{\"ok\":true}", }); var flag = new StaleConfigFlag(); var capturing = new CapturingLogger(); var reader = new ResilientConfigReader(cache, flag, capturing, timeout: TimeSpan.FromSeconds(10), retryCount: 0); // Simulated SqlException-style message carrying a connection-string fragment, the // kind of thing a poorly-wrapped delegate could surface. const string secretBearingMessage = "Login failed for user 'sa'. (Server=sql.example.com,1433;User Id=sa;Password=SuperSecret123!)"; await reader.ReadAsync( "cluster-e", _ => throw new InvalidOperationException(secretBearingMessage), snap => snap.PayloadJson, CancellationToken.None); var warning = capturing.Records.ShouldHaveSingleItem(); warning.LogLevel.ShouldBe(LogLevel.Warning); // The exception object passed as the first arg to LogWarning(ex, ...) drives the // formatter's stack-trace dump; capturing it lets us assert the scrubbing surface. warning.Exception.ShouldBeNull( "the warning must not attach the raw exception — it can carry connection-string fragments"); // The rendered message must not echo password / user-id strings even if the caller // embedded them in the exception message. warning.RenderedMessage.ShouldNotContain("Password=", Case.Insensitive); warning.RenderedMessage.ShouldNotContain("SuperSecret123!"); warning.RenderedMessage.ShouldNotContain("User Id=", Case.Insensitive); } [Fact] public async Task CallerCancellation_Propagates_NotFallback() { // Explicit caller cancellation must NOT fall back to the sealed cache — the // caller said stop, so we must stop. var cache = new GenerationSealedCache(_root); await cache.SealAsync(new GenerationSnapshot { ClusterId = "cluster-d", GenerationId = 9, CachedAt = DateTime.UtcNow, PayloadJson = "{\"should\":\"not be returned\"}", }); var flag = new StaleConfigFlag(); var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, timeout: TimeSpan.FromSeconds(10), retryCount: 0); using var cts = new CancellationTokenSource(); cts.Cancel(); await Should.ThrowAsync(async () => { await reader.ReadAsync( "cluster-d", ct => { ct.ThrowIfCancellationRequested(); return ValueTask.FromResult("ok"); }, _ => "cache-should-not-be-used", cts.Token); }); flag.IsStale.ShouldBeFalse("no cache snapshot served on genuine cancellation"); } } internal sealed record LogRecord(LogLevel LogLevel, string RenderedMessage, Exception? Exception); internal sealed class CapturingLogger : ILogger { public List Records { get; } = new(); public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Records.Add(new LogRecord(logLevel, formatter(state, exception), exception)); } private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } } [Trait("Category", "Unit")] public sealed class StaleConfigFlagTests { [Fact] public void Default_IsFresh() { new StaleConfigFlag().IsStale.ShouldBeFalse(); } [Fact] public void MarkStale_ThenFresh_Toggles() { var flag = new StaleConfigFlag(); flag.MarkStale(); flag.IsStale.ShouldBeTrue(); flag.MarkFresh(); flag.IsStale.ShouldBeFalse(); } [Fact] public void ConcurrentWrites_Converge() { var flag = new StaleConfigFlag(); Parallel.For(0, 1000, i => { if (i % 2 == 0) flag.MarkStale(); else flag.MarkFresh(); }); flag.MarkFresh(); flag.IsStale.ShouldBeFalse(); } }