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>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Polly.Timeout;
|
||||
using Shouldly;
|
||||
@@ -186,6 +187,52 @@ public sealed class ResilientConfigReaderTests : IDisposable
|
||||
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()
|
||||
{
|
||||
@@ -220,6 +267,26 @@ public sealed class ResilientConfigReaderTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user