Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ResilientConfigReaderTests.cs
Joseph Doherty b92fea15d4 fix(configuration): resolve Low code-review findings (Configuration-004,005,007,010,011)
- Configuration-004: NodePermissions stored as int to match the EF
  HasConversion<int>() in OtOpcUaConfigDbContext.ConfigureNodeAcl.
- Configuration-005: serialise LiteDbConfigCache.PutAsync so concurrent
  Put for the same (ClusterId, GenerationId) cannot duplicate rows.
- Configuration-007: rethrow OperationCanceledException from
  GenerationApplier.ApplyPass when the caller's token is cancelled.
- Configuration-010: scrub secrets and drop the full exception object
  from the ResilientConfigReader fallback warning log.
- Configuration-011: pin the previously-uncovered GenerationApplier
  cancellation and path-length / publish-validation paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 05:38:18 -04:00

321 lines
12 KiB
C#

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<ResilientConfigReader>.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<ResilientConfigReader>.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<ResilientConfigReader>.Instance,
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
await Should.ThrowAsync<GenerationCacheUnavailableException>(async () =>
{
await reader.ReadAsync<string>(
"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<ResilientConfigReader>.Instance,
timeout: TimeSpan.FromSeconds(10), retryCount: 5);
using var cts = new CancellationTokenSource();
cts.Cancel();
var attempts = 0;
await Should.ThrowAsync<OperationCanceledException>(async () =>
{
await reader.ReadAsync<string>(
"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<ResilientConfigReader>.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<ResilientConfigReader>.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<ResilientConfigReader>();
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<ResilientConfigReader>.Instance,
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
using var cts = new CancellationTokenSource();
cts.Cancel();
await Should.ThrowAsync<OperationCanceledException>(async () =>
{
await reader.ReadAsync<string>(
"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<T> : ILogger<T>
{
public List<LogRecord> Records { get; } = new();
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> 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();
}
}