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:
@@ -128,4 +128,94 @@ public sealed class GenerationApplierTests
|
||||
result.Succeeded.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("tag-bad") && e.Contains("simulated"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Configuration-011 — pin the documented ordering behaviour: a thrown Removed callback
|
||||
// records an entity error but the applier still runs the Added/Modified passes (the
|
||||
// current contract — see GenerationApplier comment about cascades settling).
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_continues_to_Added_pass_when_a_Removed_callback_throws()
|
||||
{
|
||||
var callLog = new List<string>();
|
||||
var applier = new GenerationApplier(new ApplyCallbacks
|
||||
{
|
||||
OnTag = (c, _) =>
|
||||
{
|
||||
callLog.Add($"tag:{c.Kind}:{c.LogicalId}");
|
||||
if (c.Kind == ChangeKind.Removed)
|
||||
throw new InvalidOperationException("removed-failed");
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
});
|
||||
|
||||
var from = SnapshotWith(tags: [Tag("tag-old", "X")]);
|
||||
var to = SnapshotWith(tags: [Tag("tag-new", "Y")]);
|
||||
|
||||
var result = await applier.ApplyAsync(from, to, CancellationToken.None);
|
||||
|
||||
result.Succeeded.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("tag-old") && e.Contains("removed-failed"));
|
||||
// The Added pass still runs even though Removed failed.
|
||||
callLog.ShouldContain("tag:Removed:tag-old");
|
||||
callLog.ShouldContain("tag:Added:tag-new");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Configuration-007 — ApplyPass must propagate OperationCanceledException rather than
|
||||
// recording it as an entity error. Cancellation between passes must also halt the apply.
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_propagates_OperationCanceledException_from_callback_when_token_cancelled()
|
||||
{
|
||||
// A callback that observes a cancelled token and throws OperationCanceledException
|
||||
// must abort the entire apply, not be silently swallowed and recorded as an error.
|
||||
using var cts = new CancellationTokenSource();
|
||||
var applier = new GenerationApplier(new ApplyCallbacks
|
||||
{
|
||||
OnTag = (c, ct) =>
|
||||
{
|
||||
cts.Cancel();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
});
|
||||
|
||||
var to = SnapshotWith(tags: [Tag("tag-1", "A")]);
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await applier.ApplyAsync(from: null, to, cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_stops_between_passes_when_cancellation_requested()
|
||||
{
|
||||
// After a Removed pass completes, the applier should observe cancellation before
|
||||
// running the Added/Modified passes — not silently keep walking.
|
||||
var callLog = new List<string>();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var applier = new GenerationApplier(new ApplyCallbacks
|
||||
{
|
||||
OnTag = (c, _) =>
|
||||
{
|
||||
callLog.Add($"tag:{c.Kind}:{c.LogicalId}");
|
||||
// Cancel after the Removed pass finishes — before the Added pass runs.
|
||||
if (c.Kind == ChangeKind.Removed) cts.Cancel();
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
});
|
||||
|
||||
// `from` has tag-1, `to` has tag-2 — produces one Removed + one Added.
|
||||
var from = SnapshotWith(tags: [Tag("tag-1", "A")]);
|
||||
var to = SnapshotWith(tags: [Tag("tag-2", "B")]);
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await applier.ApplyAsync(from, to, cts.Token));
|
||||
|
||||
callLog.ShouldContain("tag:Removed:tag-1");
|
||||
callLog.ShouldNotContain("tag:Added:tag-2",
|
||||
"Added pass must not run after cancellation observed between passes");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,38 @@ public sealed class LiteDbConfigCacheTests : IDisposable
|
||||
(await cache.GetMostRecentAsync("c-1"))!.PayloadJson.ShouldBe("{\"v\":2}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Configuration-005 — concurrent PutAsync for the same (ClusterId, GenerationId) must
|
||||
// not produce duplicate rows. The original find-then-insert was non-atomic so two racing
|
||||
// callers could both observe `existing is null` and both Insert.
|
||||
// ------------------------------------------------------------------------------------
|
||||
[Fact]
|
||||
public async Task PutAsync_concurrent_for_same_cluster_and_generation_does_not_duplicate()
|
||||
{
|
||||
using var cache = new LiteDbConfigCache(_dbPath);
|
||||
// Pre-seed gen=99 so prune keepLatest:1 has a sentinel that survives independent of
|
||||
// any potential duplicate (gen=42) row count.
|
||||
await cache.PutAsync(Snapshot("c-1", 99));
|
||||
|
||||
// Many parallel writes for the same key. Without serialization, racing find-then-insert
|
||||
// would Insert multiple rows for the same (ClusterId, GenerationId=42).
|
||||
var tasks = Enumerable.Range(0, 64).Select(_ => Task.Run(async () =>
|
||||
{
|
||||
var s = Snapshot("c-1", 42);
|
||||
await cache.PutAsync(s);
|
||||
})).ToArray();
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Count rows for gen=42 directly by inspecting the LiteDB file via a fresh handle.
|
||||
cache.Dispose();
|
||||
using var verify = new LiteDB.LiteDatabase(_dbPath);
|
||||
var col = verify.GetCollection<GenerationSnapshot>("generations");
|
||||
var gen42Count = col.Find(s => s.ClusterId == "c-1" && s.GenerationId == 42).Count();
|
||||
gen42Count.ShouldBe(1,
|
||||
$"PutAsync must upsert atomically — found {gen42Count} rows for (c-1, gen=42) after 64 concurrent puts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Corrupt_file_surfaces_as_LocalConfigCacheCorruptException()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the underlying type of <see cref="NodePermissions"/> to <c>int</c> so the SQL
|
||||
/// storage type (<c>HasConversion<int>()</c> in <c>OtOpcUaConfigDbContext</c>) and the
|
||||
/// XML doc ("Stored as int") cannot drift back into the latent <c>uint→int</c> overflow
|
||||
/// trap caught by Configuration-004.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class NodePermissionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Underlying_type_is_int_so_it_matches_HasConversion_in_DbContext()
|
||||
{
|
||||
// Configuration-004: NodePermissions was declared : uint while NodeAcl.PermissionFlags
|
||||
// is persisted via HasConversion<int>(). A bit-31 grant would overflow int and corrupt
|
||||
// the round-trip. Lock the underlying type to int.
|
||||
typeof(NodePermissions).GetEnumUnderlyingType().ShouldBe(typeof(int));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_defined_bits_round_trip_through_int_cast_without_loss()
|
||||
{
|
||||
// Belt-and-braces: every declared bit must survive a (int) round-trip — fails today
|
||||
// if anyone re-introduces a bit-31 flag while the underlying type is uint.
|
||||
foreach (NodePermissions value in Enum.GetValues<NodePermissions>())
|
||||
{
|
||||
var asInt = (int)value;
|
||||
var roundTripped = (NodePermissions)asInt;
|
||||
roundTripped.ShouldBe(value);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bitwise_combinations_round_trip_through_int_storage()
|
||||
{
|
||||
// The PermissionFlags column stores int; combinations of bits must survive the conversion.
|
||||
var combo = NodePermissions.Engineer | NodePermissions.MethodCall;
|
||||
var stored = (int)combo;
|
||||
var rebuilt = (NodePermissions)stored;
|
||||
rebuilt.ShouldBe(combo);
|
||||
rebuilt.HasFlag(NodePermissions.WriteTune).ShouldBeTrue();
|
||||
rebuilt.HasFlag(NodePermissions.MethodCall).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -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