fix(review): full code-review remediation — 5 High + Medium/Low across 16 modules

Remediation from the full per-module code review at 4307c381 (findings recorded
separately in code-reviews/).

Highs fixed:
- DeploymentManager-025/SiteRuntime-031: stop broadcasting notification lists + SMTP
  configs (incl. credentials) to sites; site purges already-persisted rows on apply
  (enforces the central-only delivery design; clears plaintext SMTP creds at rest).
- DataConnectionLayer-023: guard the native-alarm subscribe path against the
  mid-flight-unsubscribe adapter-feed leak (mirrors the DCL-021 tag-path fix).
- SiteEventLogging-024: normalize From/To query bounds to UTC (the -016 fix the
  audit trail claimed but never committed).
- KpiHistory-001: add an in-flight guard to the recorder sample tick.
- ScriptAnalysis-001: harden the trust analyzer's TPA-absent fallback (resolve
  forbidden anchors in the minimal reference set; warn on degraded mode) — anchors
  added to validation references only, never the compile gate.
(InboundAPI-026 left to the feat/ipsen-movein effort per owner decision.)

Medium/Low: DM-026 deterministic deploy-status tiebreaker; SR-027/028/029/030
native-alarm leak/phantom-active/delete-during-redeploy fixes; AL-013/014/016;
TE-024 (folder-mutation audit rows now persisted)/025; SF-025 gauge-provider
clear-on-stop; ESG-025/026; SEC-023/024/025; SCA-007/008/009; plus doc/test
accuracy COM-023/024, HOST-025/026, HM-024/025, NS-027/028.

Full-solution build 0 warnings; ~3560 tests across 18 touched suites green.
This commit is contained in:
Joseph Doherty
2026-06-20 17:55:12 -04:00
parent 4307c38117
commit fd618cf1dc
52 changed files with 2239 additions and 313 deletions
@@ -3,6 +3,8 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -88,6 +90,55 @@ public class AuditLogOptionsBindingTests
Assert.Equal("@token|@secret", ov.RedactSqlParamsMatching);
}
[Fact]
public void PurgeOptions_Bind_FromDocumentedSectionAndKeys()
{
// AuditLog-013: the design doc (Component-AuditLog.md §Configuration)
// documents the purge tuning as the nested `AuditLog:Purge` section with
// keys `IntervalHours` + `ChannelPurgeBatchSize`. This test pins that the
// code binds from EXACTLY that shape — the section path the production
// code uses (ServiceCollectionExtensions.PurgeSectionName) AND the
// documented `ChannelPurgeBatchSize` key (mapped onto the
// ChannelPurgeBatchSizeConfigured backing property via
// [ConfigurationKeyName]). It would fail against the pre-fix code, where
// the binder looked for `ChannelPurgeBatchSizeConfigured` and silently
// ignored the documented key.
const string json = """
{
"AuditLog": {
"Purge": {
"IntervalHours": 6,
"ChannelPurgeBatchSize": 1000
}
}
}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
var configuration = new ConfigurationBuilder()
.AddJsonStream(stream)
.Build();
// Section path matches production (PurgeSectionName == "AuditLog:Purge").
Assert.Equal("AuditLog:Purge", ServiceCollectionExtensions.PurgeSectionName);
var services = new ServiceCollection();
services.AddOptions<AuditLogPurgeOptions>()
.Bind(configuration.GetSection(ServiceCollectionExtensions.PurgeSectionName));
using var provider = services.BuildServiceProvider();
var opts = provider.GetRequiredService<IOptions<AuditLogPurgeOptions>>().Value;
// IntervalHours bound from the nested section (not the 24 h default).
Assert.Equal(6, opts.IntervalHours);
Assert.Equal(TimeSpan.FromHours(6), opts.Interval);
// ChannelPurgeBatchSize bound via the documented key onto the backing
// property (not the 5000 default).
Assert.Equal(1000, opts.ChannelPurgeBatchSizeConfigured);
Assert.Equal(1000, opts.ChannelPurgeBatchSize);
}
[Fact]
public void Filter_Behavior_Updates_OnConfigReload()
{
@@ -183,6 +183,62 @@ public class KpiSeriesBucketerTests
Assert.Equal(T(40), result[2].BucketStartUtc);
}
// -----------------------------------------------------------------------
// Unsorted input: last-in-iteration wins within a bucket (NOT largest timestamp)
// -----------------------------------------------------------------------
[Fact]
public void Bucket_UnsortedInput_SelectsLastInIterationNotLargestTimestamp()
{
// KpiHistory-006 regression: pins the documented contract that for unsorted input the
// bucketer selects the LAST point in iteration order within a bucket — it does NOT pick
// the largest-timestamp point. Both points below fall in bucket 0 ([0, 30)); the later
// one in iteration order, T(5)=value 1.0, arrives second, so it overwrites T(20)=value
// 99.0 even though T(20) has the larger timestamp. (For ascending-sorted input these
// coincide — last-in-iteration IS largest timestamp — which is why production is safe.)
var raw = new[]
{
new KpiSeriesPoint(T(20), 99.0), // larger timestamp, but encountered FIRST
new KpiSeriesPoint(T(5), 1.0), // smaller timestamp, but encountered LAST → wins
new KpiSeriesPoint(T(45), 7.0), // bucket 1 ([30, 60])
};
// 60-minute window / 2 buckets → 30 min each. raw.Count (3) > maxPoints (2) → downsample.
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2);
Assert.Equal(2, result.Count);
// Bucket 0: last-in-iteration (value 1.0) wins, NOT the largest-timestamp point (99.0).
Assert.Equal(1.0, result[0].Value);
Assert.Equal(7.0, result[1].Value);
}
// -----------------------------------------------------------------------
// Short series (raw.Count <= maxPoints): returned unchanged → raw capture timestamps
// -----------------------------------------------------------------------
[Fact]
public void Bucket_ShortSeries_ReturnsRawCaptureTimestampsNotBucketBoundaries()
{
// KpiHistory-005/003 regression: the short-series early-return path returns the input
// unchanged, so each output point's BucketStartUtc is the RAW capture timestamp — NOT a
// bucket-boundary timestamp (which is what the downsample path emits, asserted by
// Bucket_BucketStartUtc_IsSetToBucketStartNotRawPointTimestamp). This pins the
// intentional difference between the two return paths.
var raw = new[]
{
new KpiSeriesPoint(T(7), 1.0),
new KpiSeriesPoint(T(23), 2.0),
};
// raw.Count (2) <= maxPoints (5) → early return, same reference.
var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 5);
Assert.Same(raw, result);
// Timestamps are the raw capture instants, not bucket starts (which would be T(0), T(12), …).
Assert.Equal(T(7), result[0].BucketStartUtc);
Assert.Equal(T(23), result[1].BucketStartUtc);
}
// -----------------------------------------------------------------------
// Right-edge: point exactly at toUtc lands in the last bucket
// -----------------------------------------------------------------------
@@ -940,6 +940,31 @@ public class DeploymentManagerRepositoryTests : IDisposable
Assert.Equal("d-002", current!.DeploymentId);
}
[Fact]
public async Task GetCurrentDeploymentStatus_SameTickRecords_DeterministicTiebreakerPicksHighestId()
{
// DeploymentManager-026: deployments are insert-only, so two records for the
// same instance can tie on DeployedAt when created within the same clock tick
// (rapid redeploy, or redeploy right after a timed-out attempt). Without a
// secondary sort key the "current" read is non-deterministic. The repository
// now orders by DeployedAt DESC, THEN Id DESC, so the most recently inserted
// (highest Id) row wins the tie deterministically.
var instance = await SeedInstanceAsync();
var sameInstant = DateTimeOffset.UtcNow;
await _repository.AddDeploymentRecordAsync(
new DeploymentRecord("d-old", "admin") { InstanceId = instance.Id, DeployedAt = sameInstant });
await _repository.SaveChangesAsync();
await _repository.AddDeploymentRecordAsync(
new DeploymentRecord("d-new", "admin") { InstanceId = instance.Id, DeployedAt = sameInstant });
await _repository.SaveChangesAsync();
var current = await _repository.GetCurrentDeploymentStatusAsync(instance.Id);
Assert.NotNull(current);
// d-new was inserted second → it has the higher Id and must win the tie.
Assert.Equal("d-new", current!.DeploymentId);
}
[Fact]
public async Task DeleteDeploymentRecord_ViaStubAttachPath_RemovesEntity()
{
@@ -231,4 +231,79 @@ public class DataConnectionActorAlarmTests : TestKit
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", ""));
ExpectMsg<NativeAlarmTransitionUpdate>(u => u.Transition.Kind == AlarmTransitionKind.SnapshotComplete);
}
// ── DataConnectionLayer-023: mid-flight alarm unsubscribe must release the adapter feed ──
[Fact]
public async Task DCL023_UnsubscribeDuringInFlightAlarmSubscribe_ReleasesAdapterFeed_AndKeepsStateClean()
{
// Regression test for DataConnectionLayer-023. Previously the native-alarm
// subscribe path never inherited the DCL-021 obsolete-completion guard: if the
// last subscriber unsubscribed while the adapter alarm subscribe was in flight,
// HandleUnsubscribeAlarms could not tear down the feed (the subscription id was
// not stored yet), and the late AlarmSubscribeCompleted unconditionally stored
// _alarmSubscriptionIds[source] = id — an orphaned device-side alarm feed that
// streamed transitions to nobody for the lifetime of the adapter. After the fix,
// HandleUnsubscribeAlarms clears the in-flight marker and the late completion is
// recognized as orphaned (no subscriber remains) and released via
// UnsubscribeAlarmsAsync, with no leaked subscription id retained.
var subscribeStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseSubscribe = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var adapter = Substitute.For<IDataConnection, IAlarmSubscribableConnection>();
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var alarmable = (IAlarmSubscribableConnection)adapter;
// Park the adapter subscribe so UnsubscribeAlarmsRequest is processed first.
alarmable.SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<AlarmTransitionCallback>(), Arg.Any<CancellationToken>())
.Returns(_ =>
{
subscribeStarted.TrySetResult();
return releaseSubscribe.Task;
});
alarmable.UnsubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
"conn", adapter, _options, _health, _factory, "OpcUa")));
// Subscribe a source — block on the parked adapter subscribe.
actor.Tell(new SubscribeAlarmsRequest("c1", "instA", "conn", "Tank01", null, DateTimeOffset.UtcNow));
// The immediate SubscribeAlarmsResponse only arrives after HandleAlarmSubscribeCompleted;
// since the subscribe is parked, none is expected yet.
await subscribeStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
// The last subscriber unsubscribes while the alarm subscribe is still in flight.
actor.Tell(new UnsubscribeAlarmsRequest("unsub-c1", "instA", "conn", "Tank01", DateTimeOffset.UtcNow));
await Task.Delay(150);
// Release the parked subscribe — AlarmSubscribeCompleted is now orphaned.
releaseSubscribe.SetResult("alarm-sub-orphan");
await Task.Delay(300);
// The orphaned, just-created adapter feed must be released exactly once.
await alarmable.Received(1).UnsubscribeAlarmsAsync(
Arg.Is<string>(s => s == "alarm-sub-orphan"), Arg.Any<CancellationToken>());
// No leaked subscription id must remain: a fresh subscribe to the same source
// must open a NEW adapter feed (proving _alarmSubscriptionIds was not populated
// with the orphaned id, which would have short-circuited the adapter subscribe).
var resubStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
alarmable.SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(),
Arg.Any<AlarmTransitionCallback>(), Arg.Any<CancellationToken>())
.Returns(_ =>
{
resubStarted.TrySetResult();
return Task.FromResult("alarm-sub-2");
});
actor.Tell(new SubscribeAlarmsRequest("c2", "instB", "conn", "Tank01", null, DateTimeOffset.UtcNow));
await resubStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
ExpectMsg<SubscribeAlarmsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
// Two distinct adapter subscribes total (the orphaned one + the fresh one).
await alarmable.Received(2).SubscribeAlarmsAsync(
"Tank01", Arg.Any<string?>(), Arg.Any<AlarmTransitionCallback>(), Arg.Any<CancellationToken>());
}
}
@@ -32,6 +32,9 @@ public class ArtifactDeploymentServiceTests : TestKit
_deploymentRepo = Substitute.For<IDeploymentManagerRepository>();
_templateRepo = Substitute.For<ITemplateEngineRepository>();
_externalSystemRepo = Substitute.For<IExternalSystemRepository>();
// DeploymentManager-025/-027: the notification repo is retained only so the
// tests can assert the artifact path NEVER touches it (it is no longer a
// constructor dependency of ArtifactDeploymentService).
_notificationRepo = Substitute.For<INotificationRepository>();
_audit = Substitute.For<IAuditService>();
}
@@ -149,9 +152,8 @@ public class ArtifactDeploymentServiceTests : TestKit
{
// DeploymentManager-023: previously each per-site iteration of the deploy-many
// loop re-issued the global artifact queries (shared scripts, external systems,
// DB connections, notification lists, SMTP configs) — a textbook N+1 over the
// global sets. With three sites the queries must now be issued ONCE in total,
// regardless of site count.
// DB connections) — a textbook N+1 over the global sets. With three sites the
// queries must now be issued ONCE in total, regardless of site count.
var sites = new List<Site>
{
new("Site One", "site-1") { Id = 1 },
@@ -170,8 +172,16 @@ public class ArtifactDeploymentServiceTests : TestKit
await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>());
await _notificationRepo.Received(1).GetAllNotificationListsAsync(Arg.Any<CancellationToken>());
await _notificationRepo.Received(1).GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>());
// DeploymentManager-025/-027: notification lists and SMTP configuration are
// central-only — the artifact path must NEVER fetch them, and the per-site
// command must NEVER carry them.
await _notificationRepo.DidNotReceive().GetAllNotificationListsAsync(Arg.Any<CancellationToken>());
await _notificationRepo.DidNotReceive().GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>());
Assert.All(recorder.Received, cmd =>
{
Assert.Null(cmd.NotificationLists);
Assert.Null(cmd.SmtpConfigurations);
});
// The per-site query (data connections) DOES vary per site and must still run
// once per site.
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>());
@@ -197,8 +207,14 @@ public class ArtifactDeploymentServiceTests : TestKit
await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>());
await _notificationRepo.Received(1).GetAllNotificationListsAsync(Arg.Any<CancellationToken>());
await _notificationRepo.Received(1).GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>());
// DeploymentManager-025/-027: central-only — never fetched, never shipped.
await _notificationRepo.DidNotReceive().GetAllNotificationListsAsync(Arg.Any<CancellationToken>());
await _notificationRepo.DidNotReceive().GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>());
Assert.All(recorder.Received, cmd =>
{
Assert.Null(cmd.NotificationLists);
Assert.Null(cmd.SmtpConfigurations);
});
}
[Fact]
@@ -370,7 +370,7 @@ public class DatabaseGatewayTests
[MemberData(nameof(TransientNonSqlOutages))]
public async Task CachedWrite_NonSqlOutage_ClassifiedTransient_BuffersNotCrash(Exception outage)
{
// [1] A live outage that is NOT a SqlException must be classified TRANSIENT
// [3] A live outage that is NOT a SqlException must be classified TRANSIENT
// (buffered for retry), NOT escape unclassified to crash the script actor,
// and NOT be returned as a permanent Failed result.
var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test")
@@ -401,7 +401,7 @@ public class DatabaseGatewayTests
[Fact]
public async Task CachedWrite_CancellationRequested_PropagatesOperationCanceled_NotReclassified()
{
// [2] OperationCanceledException raised while the caller's token is
// [1] OperationCanceledException raised while the caller's token is
// cancelled must propagate UNCHANGED — never reclassified as a transient
// DB error and never buffered. Mirrors the HTTP path's first catch:
// `catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) throw;`
@@ -449,7 +449,7 @@ public class DatabaseGatewayTests
[Fact]
public async Task DeliverBuffered_NonSqlOutage_RethrowsAsTransient_SoEngineRetries()
{
// [1] on the RETRY path: a non-SqlException outage during delivery must be
// [3] on the RETRY path: a non-SqlException outage during delivery must be
// classified transient and propagate (as TransientDatabaseException) so
// the S&F engine schedules another retry — it must NOT crash/park.
var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 };
@@ -473,6 +473,71 @@ public class DatabaseGatewayTests
() => gateway.DeliverBufferedAsync(message));
}
// ── ExternalSystemGateway-025: a caller-token cancel that surfaces from the SQL
// driver as a SqlException (mid-flight cancel) must propagate as
// OperationCanceledException — never reclassified as a permanent DB error.
// The fix re-checks the caller's token at the TOP of `catch (SqlException)`
// via cancellationToken.ThrowIfCancellationRequested(), so the cancel wins
// regardless of the driver's exception shape (version-independent). ──
[Fact]
public async Task CachedWrite_CancellationSurfacingAsSqlException_PropagatesCanceled_NotReclassifiedPermanent()
{
var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 };
StubConnection(conn);
var (sf, connStr, keepAlive) = NewStoreAndForward();
using var _ = keepAlive;
using var cts = new CancellationTokenSource();
cts.Cancel();
// The SQL driver raises a SqlException on a mid-flight cancel (error
// number 0, not in the transient set — pre-fix it was reclassified as a
// PERMANENT DB error). The raw seam throws it through the production
// ExecuteWriteAsync classification so the new ThrowIfCancellationRequested
// guard at the top of `catch (SqlException)` runs end-to-end.
var sqlException = FabricateSqlException("Operation cancelled by user.", number: 0);
var gateway = new RawExecuteStubGateway(_repository, sf, onRunSql: () => throw sqlException);
await Assert.ThrowsAsync<OperationCanceledException>(
() => gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)", cancellationToken: cts.Token));
// The cancel won — it must NOT have been classified as transient (buffered)
// nor returned as a permanent Failed result.
Assert.Equal(0, ReadBufferDepth(connStr));
}
/// <summary>
/// Fabricates a <see cref="Microsoft.Data.SqlClient.SqlException"/> with a given
/// message and error number via the driver's internal <c>CreateException</c>
/// factory (the type has no public constructor). Used only to drive the
/// <c>catch (SqlException)</c> branch of <c>ExecuteWriteAsync</c> in tests.
/// </summary>
private static Microsoft.Data.SqlClient.SqlException FabricateSqlException(string message, int number)
{
var errorCtor = typeof(Microsoft.Data.SqlClient.SqlError).GetConstructors(
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
.First(c => c.GetParameters().Length == 8
&& c.GetParameters()[7].ParameterType == typeof(Exception));
var sqlError = (Microsoft.Data.SqlClient.SqlError)errorCtor.Invoke(
new object?[] { number, (byte)0, (byte)0, "server", message, "procedure", 0, null });
var collectionCtor = typeof(Microsoft.Data.SqlClient.SqlErrorCollection).GetConstructors(
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).First();
var collection = (Microsoft.Data.SqlClient.SqlErrorCollection)collectionCtor.Invoke(Array.Empty<object?>());
typeof(Microsoft.Data.SqlClient.SqlErrorCollection)
.GetMethod("Add", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.Invoke(collection, new object?[] { sqlError });
var createException = typeof(Microsoft.Data.SqlClient.SqlException).GetMethod(
"CreateException",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic,
new[] { typeof(Microsoft.Data.SqlClient.SqlErrorCollection), typeof(string) })!;
return (Microsoft.Data.SqlClient.SqlException)createException.Invoke(
null, new object?[] { collection, "6.0.0" })!;
}
/// <summary>
/// Reads the current buffered-message count off the S&amp;F SQLite DB by
/// counting <c>sf_messages</c> rows (the engine's persistence table).
@@ -142,6 +142,40 @@ public class KpiHistoryRecorderActorTests : TestKit
}
}
/// <summary>
/// Repository fake whose <see cref="RecordSamplesAsync"/> blocks on a manual-reset gate
/// until the test releases it, holding a sample pass "in flight" so an overlapping-tick
/// scenario can be driven deterministically. Counts how many times the write was entered.
/// </summary>
private sealed class GatedRepository : IKpiHistoryRepository
{
private readonly ManualResetEventSlim _release = new(initialState: false);
private int _writeCount;
/// <summary>Number of times <see cref="RecordSamplesAsync"/> has been entered.</summary>
public int WriteCount => Volatile.Read(ref _writeCount);
/// <summary>Releases all blocked writes so the gated passes can complete.</summary>
public void Release() => _release.Set();
public Task RecordSamplesAsync(
IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _writeCount);
// Block on a threadpool thread (PipeTo runs the pass off the actor thread), holding
// the pass in flight until the test opens the gate.
return Task.Run(() => _release.Wait(cancellationToken), cancellationToken);
}
public Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync(
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>());
public Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) =>
Task.FromResult(0);
}
private IServiceProvider BuildServiceProvider(
IKpiHistoryRepository repository, params IKpiSampleSource[] sources)
{
@@ -244,11 +278,62 @@ public class KpiHistoryRecorderActorTests : TestKit
duration: TimeSpan.FromSeconds(2),
interval: TimeSpan.FromMilliseconds(50));
// Second tick to the SAME actor: source now returns a healthy sample.
// AwaitAssert confirms the actor processed the message and recorded it.
// Second tick to the SAME actor: source now returns a healthy sample. Re-send the tick
// on each poll so we don't race the (asynchronous) SampleComplete that lowers the
// in-flight guard after the faulted first pass — a tick that lands before the guard
// clears is harmlessly skipped, and the next poll's tick runs. The recovered sample
// being recorded proves the actor's message loop is still alive after a faulted pass.
// (>= 1 rather than exactly-1: a later re-sent tick could run an extra recovering pass
// once the guard clears, which is not what this test pins — KH-001's one-pass-per-tick
// guard is pinned by OverlappingTick_WhileFirstPassInFlight_DoesNotStartSecondPass.)
AwaitAssert(
() =>
{
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
Assert.NotEmpty(repository.Recorded);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
[Fact]
public void OverlappingTick_WhileFirstPassInFlight_DoesNotStartSecondPass()
{
// KpiHistory-001 regression: the in-flight guard must coalesce a tick that arrives
// while a prior sample pass is still awaiting its DB write. With a gated repository
// holding the first write open, a second SampleTick must NOT spawn a second pass —
// so RecordSamplesAsync is entered exactly once until the gate is released.
var repository = new GatedRepository();
var sp = BuildServiceProvider(repository, new HealthySource());
var actor = CreateActor(sp);
// First tick: raises the guard, enters the (gated, blocking) write — held in flight.
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
AwaitAssert(
() => Assert.Single(repository.Recorded),
() => Assert.Equal(1, repository.WriteCount),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
// Second tick while the first pass is still in flight: must be skipped by the guard.
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
// Give the second tick ample time to (wrongly) start a pass; the write count must
// stay at 1, proving no second concurrent pass was launched.
Assert.Equal(1, repository.WriteCount);
Thread.Sleep(300);
Assert.Equal(1, repository.WriteCount);
// Release the gate so the first pass completes and the guard is lowered; a fresh
// tick must now run a new pass (guard correctly reset, not stuck). Re-send the tick on
// each poll so we don't race the (asynchronous) SampleComplete that lowers the guard —
// a tick that lands before the guard clears is harmlessly skipped, the next one runs.
repository.Release();
AwaitAssert(
() =>
{
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
Assert.Equal(2, repository.WriteCount);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
@@ -0,0 +1,117 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Security-023 (membership-validation half): the LDAP-group-mapping <c>Role</c> arrives
/// as a free string over the CLI/Management API. The single server-side write path
/// (<c>ManagementActor.HandleCreateRoleMapping</c> / <c>HandleUpdateRoleMapping</c>) now
/// rejects any role that is not in the canonical <see cref="Roles.All"/> set, returning a
/// <c>COMMAND_FAILED</c> error before any DB write. A non-canonical role never functioned
/// (no authorization policy or ManagementActor check matched it), so rejecting it removes
/// a silent-misconfiguration footgun rather than changing behaviour.
/// </summary>
/// <remarks>
/// Per Security-023's scope: only the membership check is added. The pre-existing
/// case-sensitivity asymmetry (case-sensitive UI <c>RequireClaim</c> vs case-insensitive
/// ManagementActor authz) is a separately-deferred change and is NOT touched — the
/// membership check accepts a correctly-cased canonical role and stores it verbatim.
/// </remarks>
public class RoleMappingValidationTests : TestKit, IDisposable
{
private readonly ISecurityRepository _securityRepo;
private readonly IAuditService _auditService;
private readonly ServiceCollection _services;
public RoleMappingValidationTests()
{
_securityRepo = Substitute.For<ISecurityRepository>();
_auditService = Substitute.For<IAuditService>();
_services = new ServiceCollection();
_services.AddScoped(_ => _securityRepo);
_services.AddScoped(_ => _auditService);
}
private IActorRef CreateActor()
{
var sp = _services.BuildServiceProvider();
return Sys.ActorOf(Props.Create(() => new ManagementActor(
sp, NullLogger<ManagementActor>.Instance)));
}
// CreateRoleMappingCommand / UpdateRoleMappingCommand both require Administrator
// (see ManagementActor.GetRequiredRole), so the test principal carries that role.
private static ManagementEnvelope Envelope(object command) =>
new(new AuthenticatedUser("admin", "admin", new[] { Roles.Administrator }, Array.Empty<string>()),
command, Guid.NewGuid().ToString("N"));
void IDisposable.Dispose() => Shutdown();
[Fact]
public void CreateRoleMapping_UnknownRole_ReturnsError_NoRowInserted()
{
var actor = CreateActor();
actor.Tell(Envelope(new CreateRoleMappingCommand("SCADA-Admins", "Wizard")));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Wizard", response.Error);
_securityRepo.DidNotReceiveWithAnyArgs().AddMappingAsync(default!, default);
}
[Fact]
public void CreateRoleMapping_MisspelledCanonicalRole_ReturnsError_NoRowInserted()
{
// A pure typo of a real role ("Deploer" for "Deployer") is exactly the silent
// footgun Security-023 describes: it would stamp a role claim no policy matches.
var actor = CreateActor();
actor.Tell(Envelope(new CreateRoleMappingCommand("SCADA-Deploy", "Deploer")));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
_securityRepo.DidNotReceiveWithAnyArgs().AddMappingAsync(default!, default);
}
[Fact]
public void UpdateRoleMapping_UnknownRole_ReturnsError_NoRowUpdated()
{
var actor = CreateActor();
actor.Tell(Envelope(new UpdateRoleMappingCommand(7, "SCADA-Admins", "Wizard")));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("Wizard", response.Error);
// Rejected before the mapping is even looked up.
_securityRepo.DidNotReceiveWithAnyArgs().GetMappingByIdAsync(default, default);
_securityRepo.DidNotReceiveWithAnyArgs().UpdateMappingAsync(default!, default);
}
[Fact]
public void CreateRoleMapping_CanonicalRole_Succeeds_RowInserted()
{
LdapGroupMapping? inserted = null;
_securityRepo
.When(r => r.AddMappingAsync(Arg.Any<LdapGroupMapping>(), Arg.Any<CancellationToken>()))
.Do(ci => inserted = ci.Arg<LdapGroupMapping>());
var actor = CreateActor();
actor.Tell(Envelope(new CreateRoleMappingCommand("SCADA-Deploy", Roles.Deployer)));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(inserted);
// Membership check does not alter the stored value's casing (case-sensitivity
// asymmetry is a separately-deferred change).
Assert.Equal(Roles.Deployer, inserted!.Role);
Assert.Equal("SCADA-Deploy", inserted.LdapGroupName);
}
}
@@ -1,13 +1,11 @@
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Kpi;
namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests.Kpi;
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Kpi;
/// <summary>
/// Tests for <see cref="NotificationOutboxKpiSampleSource"/> — the M6 KPI sample source that
@@ -8,8 +8,8 @@ namespace ZB.MOM.WW.ScadaBridge.NotificationService.Tests;
/// NS-002/NS-003: Tests for the shared SMTP error classification policy. This
/// policy is correctness-relevant — it decides whether a delivery failure is
/// retried (transient) or returned to the caller (permanent) — and is shared
/// between <see cref="NotificationDeliveryService"/> and the central outbox's
/// <c>EmailNotificationDeliveryAdapter</c>, so it deserves direct coverage.
/// by the central outbox's <c>EmailNotificationDeliveryAdapter</c>, so it
/// deserves direct coverage.
/// </summary>
public class SmtpErrorClassifierTests
{
@@ -193,4 +193,132 @@ public class ScriptTrustValidatorTests
var code = "var n = System.Linq.Enumerable.Range(0,3).Sum(); var m = System.Math.Max(1,2);";
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
// ---- ScriptAnalysis-003: adversarial bypass-vector coverage --------------
// (a) TPA-FALLBACK DEGRADATION (the SA-001 hole). Forces Pass 1 onto the
// minimal fallback reference set (DefaultAssemblies + ForbiddenAnchorAssemblies)
// — the set used on a single-file/AOT/trimmed host with no TPA list — and
// proves a BARE forbidden type inside an ALLOWED namespace is STILL caught.
// Before the fix, `Process` resolved to nothing against the minimal set, the
// syntactic fallback ignored the dotless identifier, and Pass 2 never flags a
// bare identifier — so `Process.Start` slipped the validator entirely. The
// anchor assemblies folded into the fallback close that hole.
[Fact]
public void TpaFallback_StillRejects_BareProcess_ViaUsing()
{
// The documented forbidden-type-in-allowed-namespace case: System.Diagnostics
// is allowed (Stopwatch/Debug), the `using` is not flagged, and `Process`
// is a BARE identifier. Against the minimal fallback set this must still
// be rejected — otherwise the SA-001 fallback hole is open.
var minimal = ScriptTrustPolicy.BuildMinimalFallbackReferences();
var code = "using System.Diagnostics; var p = Process.Start(\"x\");";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code, minimal));
}
[Fact]
public void TpaFallback_StillRejects_BareSocket_ViaUsing()
{
// System.Net.Sockets.Socket lives in its own assembly (not CoreLib); the
// anchor set must include it so the minimal fallback still resolves a bare
// `Socket` reference.
var minimal = ScriptTrustPolicy.BuildMinimalFallbackReferences();
var code = "using System.Net.Sockets; Socket s = null;";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code, minimal));
}
[Fact]
public void TpaFallback_StillAllows_DiagnosticsStopwatch()
{
// Control: the fallback must not over-block — Stopwatch (System.Diagnostics,
// allowed) stays clean even against the minimal anchor-enriched set.
var minimal = ScriptTrustPolicy.BuildMinimalFallbackReferences();
var code = "var sw = System.Diagnostics.Stopwatch.StartNew();";
Assert.Empty(ScriptTrustValidator.FindViolations(code, minimal));
}
[Fact]
public void MinimalFallbackReferences_Resolve_Process_AsForbidden()
{
// Pins the resolution mechanism directly: against the minimal fallback set,
// bare `Process` resolves to its true namespace and is reported by the
// semantic pass (the message names the forbidden scope), not merely caught
// by some incidental syntactic rule.
var minimal = ScriptTrustPolicy.BuildMinimalFallbackReferences();
var violations = ScriptTrustValidator.FindViolations(
"using System.Diagnostics; var p = Process.Start(\"x\");", minimal);
Assert.Contains(violations, v => v.Contains("System.Diagnostics.Process", StringComparison.Ordinal));
}
// (b) EXTENSION-METHOD invocation of a forbidden API. `asm.GetCustomAttribute<T>()`
// resolves to the extension method on System.Reflection.CustomAttributeExtensions
// (a forbidden namespace) even though it is invoked in receiver position — the
// semantic pass resolves the reduced extension method's containing type, so the
// forbidden namespace is caught through the invocation itself.
[Fact]
public void Rejects_ExtensionMethod_InForbiddenNamespace()
{
var code =
"using System.Reflection; " +
"Assembly asm = typeof(string).Assembly; " +
"var a = asm.GetCustomAttribute<System.ObsoleteAttribute>();";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
// (c) VERBATIM (@) and UNICODE-ESCAPE spellings of a forbidden identifier.
// VisitIdentifierName compares Identifier.ValueText, which decodes both the
// verbatim '@' prefix and \uXXXX escapes — so neither spelling evades the
// ForbiddenIdentifiers deny-list.
[Fact]
public void Rejects_VerbatimIdentifier_Activator()
{
var code = "var o = @Activator.CreateInstance(typeof(string));";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
[Fact]
public void Rejects_UnicodeEscapedIdentifier_Activator()
{
// 'A' is 'A' — the token spells "Activator" but ValueText is "Activator".
var code = "var o = \\u0041ctivator.CreateInstance(typeof(string));";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
// (d) UNSAFE block. The validator is a forbidden-API deny-list, not a
// language-feature gate: a benign `unsafe` block reaches no forbidden API, so
// it must NOT be a false positive — while a forbidden API used INSIDE an unsafe
// block is still caught (the walker descends into the block).
[Fact]
public void Allows_BenignUnsafeBlock_NoForbiddenApi()
{
var code = "unsafe { int x = 1; int* p = &x; var y = *p; }";
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
[Fact]
public void Rejects_ForbiddenApi_InsideUnsafeBlock()
{
var code = "unsafe { var t = typeof(string).Assembly; }";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
// (e) COMMENT / STRING-LITERAL must NOT cause a false positive. A forbidden
// namespace mentioned only in trivia or a string literal reaches no API and
// must stay clean (the walker inspects name/member nodes, never trivia or
// literal text). Reconstructing a forbidden API from runtime strings is outside
// the static validator's remit (documented sandbox caveat).
[Fact]
public void Allows_ForbiddenNamespace_InCommentOnly()
{
var code = "// using System.IO; File.ReadAllText(\"x\")\nvar y = 1;";
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
[Fact]
public void Allows_ForbiddenNamespace_InStringLiteralOnly()
{
var code = "var s = \"System.IO.File.ReadAllText\";";
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
}
@@ -71,12 +71,16 @@ public class LdapAuthServiceTests
[Fact]
public async Task AuthenticateAsync_ConnectionFailure_FailsClosed_NeverThrows()
{
// Point at a non-existent server: the library fails closed (never throws) and
// maps the unreachable directory to the system-side ServiceAccountBindFailed
// bucket — preserving the donor's "directory unavailable ⇒ login fails" rule.
// Security-025: point at a guaranteed-unroutable loopback address (127.0.0.1 on a
// closed high port) rather than DNS-resolving "nonexistent.invalid". The connect is
// refused deterministically and immediately, with no external DNS dependency and no
// multi-second timeout dead-time, so this stays a fast, network-sandbox-safe unit
// test. The library still fails closed (never throws) and maps the unreachable
// directory to the system-side ServiceAccountBindFailed bucket — preserving the
// donor's "directory unavailable ⇒ login fails" rule, which is what this asserts.
var options = CreateOptions(LdapTransport.None, allowInsecure: true) with
{
Server = "nonexistent.invalid",
Server = "127.0.0.1",
Port = 9999,
ConnectionTimeoutMs = 2_000,
};
@@ -1159,6 +1163,85 @@ public class AuthorizationPolicyTests
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
}
// ─────────────────────────────────────────────────────────────────────
// Security-024 — M7 two-person Secured Write separation-of-duties (SoD)
// policies. The whole safety argument of Secured Writes is that the role
// that INITIATES (Operator → RequireOperator) is distinct from the role
// that APPROVES (Verifier → RequireVerifier). These functional
// AuthorizeAsync tests prove the grant/deny behaviour and the mutual
// distinctness — a regression that mapped, say, RequireOperator to
// Roles.Verifier would now fail here instead of silently collapsing the
// SoD (the prior coverage only asserted the constant string values).
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task OperatorPolicy_OperatorRole_Succeeds()
{
var principal = CreatePrincipal(new[] { Roles.Operator });
Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireOperator, principal));
}
[Theory]
[InlineData("Verifier")]
[InlineData("Administrator")]
[InlineData("Designer")]
[InlineData("Deployer")]
[InlineData("Viewer")]
public async Task OperatorPolicy_NonOperatorRoles_Fail(string role)
{
var principal = CreatePrincipal(new[] { role });
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireOperator, principal));
}
[Fact]
public async Task OperatorPolicy_NoRoles_Fails()
{
var principal = CreatePrincipal(Array.Empty<string>());
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireOperator, principal));
}
[Fact]
public async Task VerifierPolicy_VerifierRole_Succeeds()
{
var principal = CreatePrincipal(new[] { Roles.Verifier });
Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireVerifier, principal));
}
[Theory]
[InlineData("Operator")]
[InlineData("Administrator")]
[InlineData("Designer")]
[InlineData("Deployer")]
[InlineData("Viewer")]
public async Task VerifierPolicy_NonVerifierRoles_Fail(string role)
{
var principal = CreatePrincipal(new[] { role });
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireVerifier, principal));
}
[Fact]
public async Task VerifierPolicy_NoRoles_Fails()
{
var principal = CreatePrincipal(Array.Empty<string>());
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireVerifier, principal));
}
[Fact]
public async Task SecuredWriteSoD_OperatorPrincipalCannotSatisfyVerifier_AndViceVersa()
{
// The SoD invariant at the policy layer: an Operator-only principal can
// initiate (RequireOperator) but cannot approve (RequireVerifier), and a
// Verifier-only principal is the mirror. The two policies are mutually
// distinct, so a single role can never both initiate and approve.
var operatorOnly = CreatePrincipal(new[] { Roles.Operator });
Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireOperator, operatorOnly));
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireVerifier, operatorOnly));
var verifierOnly = CreatePrincipal(new[] { Roles.Verifier });
Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireVerifier, verifierOnly));
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireOperator, verifierOnly));
}
private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null)
{
var claims = new List<Claim>();
@@ -1,5 +1,6 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
@@ -177,4 +178,48 @@ public class SiteCallAuditPurgeTests : TestKit
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// ---------------------------------------------------------------------
// 4. SiteCallAudit-007: purge timer arms even when the reconciliation
// collaborators are ABSENT (production ctor, no IPullSiteCallsClient /
// ISiteEnumerator registered). Proves the decoupling — a host that omits
// the reconciliation client still purges, so the central SiteCalls table
// cannot grow unbounded.
// ---------------------------------------------------------------------
[Fact]
public void PurgeTick_ProductionCtor_NoReconciliationCollaborators_StillPurges()
{
var repo = new RecordingRepo { RowsDeletedPerCall = 3 };
// Build a DI container that registers the repository the production
// ctor resolves per-tick, but deliberately registers NEITHER
// IPullSiteCallsClient NOR ISiteEnumerator. GetService returns null for
// both, so the actor's reconciliation tick is gated off — but the purge
// tick must still arm (SiteCallAudit-007).
var provider = new ServiceCollection()
.AddScoped<ISiteCallAuditRepository>(_ => repo)
.BuildServiceProvider();
var options = FastPurgeOptions(retentionDays: 30);
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
provider,
options,
NullLogger<SiteCallAuditActor>.Instance)));
// No reconciliation collaborators were registered, yet the purge tick
// must still fire on the production path.
AwaitAssert(
() => Assert.True(repo.PurgeThresholds.Count >= 1,
"purge timer must arm even when the reconciliation collaborators are absent "
+ $"(SiteCallAudit-007), got {repo.PurgeThresholds.Count} purge calls"),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
var threshold = repo.PurgeThresholds[0];
var expected = DateTime.UtcNow - TimeSpan.FromDays(30);
Assert.True(
Math.Abs((threshold - expected).TotalMinutes) < 1.0,
$"purge threshold {threshold:o} should be within 1 minute of {expected:o}");
}
}
@@ -107,6 +107,30 @@ public class SiteCallAuditReconciliationTests : TestKit
}
}
/// <summary>
/// Pull client that ALWAYS returns the same saturated response
/// (<c>MoreAvailable=true</c>) regardless of the <c>since</c> cursor —
/// simulates the SiteCallAudit-009 single-timestamp no-progress pin: a backlog
/// larger than the batch size all sharing one exact <c>UpdatedAtUtc</c>, so
/// the inclusive max-timestamp cursor never advances. Records every call so
/// the test can assert the within-tick drain is BOUNDED (the actor must not
/// spin the dispatcher forever on this pathological input).
/// </summary>
private sealed class SaturatedPinPullClient : IPullSiteCallsClient
{
private readonly IReadOnlyList<SiteCall> _rows;
public int CallCount { get; private set; }
public SaturatedPinPullClient(IReadOnlyList<SiteCall> rows) => _rows = rows;
public Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, int batchSize, CancellationToken ct)
{
CallCount++;
return Task.FromResult(new PullSiteCallsResponse(_rows, MoreAvailable: true));
}
}
/// <summary>
/// Recording repository that captures every <see cref="UpsertAsync"/> call
/// (keyed by id, last-write-wins on the captured row). The reconciliation
@@ -301,4 +325,115 @@ public class SiteCallAuditReconciliationTests : TestKit
// so it upserts nothing on its own.
Assert.Equal(0, repo.UpsertCallCount);
}
// ---------------------------------------------------------------------
// 5. SiteCallAudit-009: MoreAvailable drives a within-tick continuation
// drain — a multi-page backlog whose timestamps advance is fully drained
// in ONE tick rather than one page per tick.
// ---------------------------------------------------------------------
[Fact]
public void ReconciliationTick_MoreAvailable_DrainsMultiplePagesWithinOneTick()
{
var siteId = "siteA";
var t1 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
var t2 = new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc);
var t3 = new DateTime(2026, 5, 20, 10, 2, 0, DateTimeKind.Utc);
var p1a = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: t1);
var p1b = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: t2);
var p2 = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: t3);
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteA:8083"));
// Page 1 saturates (MoreAvailable: true) → the actor continues pulling
// within the SAME tick; page 2 is the final page (MoreAvailable: false).
// The continuation pull's `since` must be t2 (page-1 max), proving the
// cursor advanced page-to-page inside one tick rather than across ticks.
var client = new ScriptedPullClient().Script(siteId,
new PullSiteCallsResponse(new[] { p1a, p1b }, MoreAvailable: true),
new PullSiteCallsResponse(new[] { p2 }, MoreAvailable: false));
var repo = new RecordingRepo();
// Slow tick so the multi-page drain CANNOT be the natural tick cadence —
// it must be the within-tick continuation loop. Long enough that only the
// first tick fires in the assert window.
var options = new SiteCallAuditOptions
{
ReconciliationIntervalOverride = TimeSpan.FromSeconds(2),
ReconciliationBatchSize = 2,
};
CreateActor(sites, client, repo, options);
AwaitAssert(
() =>
{
// All three rows reconciled — including the page-2 row that only a
// within-tick continuation pull could have fetched.
Assert.True(repo.Upserted.ContainsKey(p1a.TrackedOperationId));
Assert.True(repo.Upserted.ContainsKey(p1b.TrackedOperationId));
Assert.True(repo.Upserted.ContainsKey(p2.TrackedOperationId),
"the page-2 row must be reconciled within the same tick via the MoreAvailable continuation drain");
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
// Exactly two pulls happened (page 1 + the continuation page 2) and the
// second pull's `since` cursor advanced to the page-1 max (t2).
Assert.True(client.Calls.Count >= 2, $"expected >= 2 pulls within the tick, got {client.Calls.Count}");
Assert.Equal(DateTime.MinValue, client.Calls[0].SinceUtc);
Assert.Equal(t2, client.Calls[1].SinceUtc);
}
// ---------------------------------------------------------------------
// 6. SiteCallAudit-009: single-timestamp saturation pin does NOT spin —
// a saturated batch whose max UpdatedAtUtc never advances past `since`
// breaks the within-tick drain after one page (no unbounded re-pull),
// and still upserts the rows it saw.
// ---------------------------------------------------------------------
[Fact]
public void ReconciliationTick_SingleTimestampSaturation_DoesNotSpin_MakesNoProgressGracefully()
{
var siteId = "siteA";
// A burst sharing ONE exact UpdatedAtUtc that saturates the batch — the
// inclusive max-timestamp cursor cannot advance, so an unbounded
// continuation loop would re-pull this identical window forever.
var ts = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
var r1 = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: ts);
var r2 = NewRow(TrackedOperationId.New(), siteId, updatedAtUtc: ts);
var sites = new StaticEnumerator(new SiteEntry(siteId, "http://siteA:8083"));
var client = new SaturatedPinPullClient(new[] { r1, r2 });
var repo = new RecordingRepo();
// Long interval so AT MOST one tick fires in the assert window — lets us
// bound the WITHIN-tick pull count. A no-progress pin must break after a
// single page, NOT loop up to MaxReconciliationPagesPerTick (50).
var options = new SiteCallAuditOptions
{
ReconciliationIntervalOverride = TimeSpan.FromSeconds(2),
ReconciliationBatchSize = 2,
};
CreateActor(sites, client, repo, options);
AwaitAssert(
() => Assert.True(client.CallCount >= 1, "the first reconciliation tick should have pulled"),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
// The rows it saw were still upserted (idempotent mirror refresh).
Assert.True(repo.Upserted.ContainsKey(r1.TrackedOperationId));
Assert.True(repo.Upserted.ContainsKey(r2.TrackedOperationId));
// Critical SiteCallAudit-009 invariant: the within-tick drain BROKE on the
// no-progress pin rather than looping to the 50-page ceiling. With a 2s
// tick interval, only the first tick has fired in the window, so the pull
// count reflects ONE tick's within-loop behaviour. A correct break yields
// 1 pull for that tick; we allow a small margin for a possible second tick
// edge, but it must be far below the 50-page within-tick ceiling.
Assert.True(client.CallCount < 10,
$"a single-timestamp saturation pin must break the within-tick drain, not spin to the "
+ $"page ceiling; got {client.CallCount} pulls (an unbounded within-tick loop would be 50+)");
}
}
@@ -213,6 +213,42 @@ public class EventLogQueryServiceTests : IDisposable
Assert.Equal(2, response.Entries.Count);
}
[Fact]
public async Task Query_FiltersByTimeRange_HandlesNonUtcOffset()
{
// SiteEventLogging-024 (re-opens -016): the store holds UTC ISO-8601 "o"
// strings (always +00:00) and compares them lexicographically. If the
// From/To bounds are stringified verbatim without UTC normalisation, a
// non-UTC DateTimeOffset from a central client sorts wrongly against the
// stored +00:00 values and the wrong rows are returned. This test seeds
// events at known UTC instants and queries with bounds expressed in a
// +05:00 offset that bracket the middle row; it FAILS against the unfixed
// code (verbatim ToString("o")) and PASSES once From/To are normalised with
// .ToUniversalTime().
// Three events at distinct, well-separated UTC instants. The recorder always
// stores UTC, so seed the rows as UTC to mirror real data.
var baseUtc = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero);
InsertEventAt(baseUtc.AddHours(-2), "script", "Info", null, "EARLY", "Early event"); // 10:00 UTC
InsertEventAt(baseUtc, "script", "Info", null, "MIDDLE", "Middle event"); // 12:00 UTC
InsertEventAt(baseUtc.AddHours(2), "script", "Info", null, "LATE", "Late event"); // 14:00 UTC
// Express the SAME wall-clock window the operator intends — 11:00..13:00 UTC —
// but as a +05:00 DateTimeOffset (16:00..18:00 local). These bound only the
// MIDDLE row. With the bug, ToString("o") emits "...+05:00" which compares
// wrongly against the stored "...+00:00" rows.
var offset = TimeSpan.FromHours(5);
var fromNonUtc = new DateTimeOffset(2026, 6, 1, 16, 0, 0, offset); // == 11:00 UTC
var toNonUtc = new DateTimeOffset(2026, 6, 1, 18, 0, 0, offset); // == 13:00 UTC
var response = _queryService.ExecuteQuery(MakeRequest(from: fromNonUtc, to: toNonUtc));
Assert.True(response.Success);
// Assert on row IDENTITIES, not just the count: only the MIDDLE row falls in
// the 11:00..13:00 UTC window.
Assert.Equal(new[] { "MIDDLE" }, response.Entries.Select(e => e.Source).ToArray());
}
[Fact]
public async Task Query_EmptyResult_WhenNoMatches()
{
@@ -184,6 +184,62 @@ public class DeploymentManagerRedeployTests : TestKit, IDisposable
Assert.True(disable.Success);
}
[Fact]
public async Task SR029_DeleteDuringPendingRedeploy_InstanceStaysDeleted_AndCounterIsCorrect()
{
// Regression test for SiteRuntime-029. A delete arriving WHILE a redeploy is
// still terminating used to: (1) over-decrement _totalDeployedCount, and
// (2) leave the buffered _pendingRedeploys entry intact — so when Terminated
// fired, HandleTerminated called ApplyDeployment(isRedeploy: true) and
// RESURRECTED the just-deleted instance (re-creating the actor and re-writing
// the deployed-config SQLite row). After the fix, HandleDelete is authoritative
// over the mid-redeploy bookkeeping: it cancels the pending redeploy (telling
// the displaced deployer it was superseded), clears the terminating shadow, and
// decrements the counter exactly once.
var health = new CountCapturingHealthCollector();
var actor = CreateDeploymentManager(health);
await Task.Delay(500);
// Establish the running instance.
actor.Tell(new DeployInstanceCommand(
"dep-1", "RaceTarget", "h1", MakeConfigJson("RaceTarget"), "admin", DateTimeOffset.UtcNow));
var first = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
Assert.Equal(DeploymentStatus.Success, first.Status);
await Task.Delay(300);
// Fire a redeploy immediately followed by a delete. Both queue on the
// singleton mailbox: HandleDeploy runs first (removes from _instanceActors,
// watches + stops the predecessor, buffers the redeploy, sets the terminating
// shadow), then HandleDelete runs while the predecessor is still terminating
// (Terminated has not fired) — exactly the SiteRuntime-029 window.
var redeployProbe = CreateTestProbe();
actor.Tell(new DeployInstanceCommand(
"dep-2", "RaceTarget", "h2", MakeConfigJson("RaceTarget"), "admin", DateTimeOffset.UtcNow),
redeployProbe.Ref);
actor.Tell(new DeleteInstanceCommand("del-1", "RaceTarget", DateTimeOffset.UtcNow));
// The delete succeeds...
var delete = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(10));
Assert.True(delete.Success);
// ...and the displaced redeploy is told it was superseded (not silently lost).
var superseded = redeployProbe.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
Assert.Equal("dep-2", superseded.DeploymentId);
Assert.Equal(DeploymentStatus.Failed, superseded.Status);
Assert.Contains("superseded", superseded.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
// Give the predecessor's Terminated signal time to fire — it must NOT
// resurrect the deleted instance.
await Task.Delay(1000);
// The instance stays deleted: no deployed-config row remains.
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "RaceTarget");
// The deployed count is back to 0 — neither over-decremented nor resurrected.
Assert.Equal(0, health.LastDeployedCount);
}
[Fact]
public async Task Redeploy_ExistingInstance_DoesNotOverCountDeployedInstances()
{
@@ -9,6 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
@@ -44,8 +45,12 @@ public class NativeAlarmActorTests : TestKit, IDisposable
"Process", "hi", "hi", "", "", null, time ?? DateTimeOffset.UtcNow, "92", "90");
private IActorRef Spawn(IActorRef instanceActor, IActorRef dclManager, IServiceProvider? serviceProvider = null) =>
Spawn(instanceActor, dclManager, _options, serviceProvider);
private IActorRef Spawn(IActorRef instanceActor, IActorRef dclManager, SiteRuntimeOptions options,
IServiceProvider? serviceProvider = null) =>
ActorOf(Props.Create(() => new NativeAlarmActor(
Source(), "inst", instanceActor, dclManager, _storage, _options,
Source(), "inst", instanceActor, dclManager, _storage, options,
NullLogger<NativeAlarmActor>.Instance, AlarmKind.NativeOpcUa, serviceProvider)));
[Fact]
@@ -297,6 +302,74 @@ public class NativeAlarmActorTests : TestKit, IDisposable
}, TimeSpan.FromSeconds(2));
}
// ── SiteRuntime-028: cap eviction emits a return-to-normal for an active drop ──
[Fact]
public void EnforceCap_EvictsActiveOldestCondition_EmitsReturnToNormalAndDropSignal()
{
// SiteRuntime-028: a cap eviction that drops a still-Active condition without a
// return-to-normal leaves the Instance Actor (and central's stream / the
// operator Alarm Summary) showing a phantom Active alarm forever. With cap=1,
// raising a second condition evicts the oldest (still Active) one — which must
// produce a Normal AlarmStateChanged for the evicted SourceReference, plus the
// SiteRuntime-027 NativeAlarmDropped so the parent evicts its stale key.
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var options = new SiteRuntimeOptions { MirroredAlarmCapPerSource = 1 };
var actor = Spawn(instance.Ref, dcl.Ref, options);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var t0 = DateTimeOffset.UtcNow;
// Oldest active condition (will be evicted when the cap is exceeded).
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"OLD.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
instance.ExpectMsg<AlarmStateChanged>(m => m.SourceReference == "OLD.Hi" && m.State == AlarmState.Active);
// Newer active condition pushes the set to 2 > cap(1) → OLD.Hi is evicted.
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"NEW.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0.AddSeconds(5))));
// The new condition is emitted active...
instance.ExpectMsg<AlarmStateChanged>(m => m.SourceReference == "NEW.Hi" && m.State == AlarmState.Active);
// ...and the evicted oldest condition must be cleared (return-to-normal), not
// left phantom-active.
instance.ExpectMsg<AlarmStateChanged>(m => m.SourceReference == "OLD.Hi" && m.State == AlarmState.Normal);
// ...and the parent is told to evict the stale _latestAlarmEvents key.
instance.ExpectMsg<NativeAlarmDropped>(m => m.SourceReference == "OLD.Hi");
}
// ── SiteRuntime-027: terminal retention drop signals the parent to evict its key ──
[Fact]
public void RetentionDrop_ResolvedCondition_EmitsReturnToNormalThenDropSignal()
{
// SiteRuntime-027: when a native condition resolves (inactive AND acknowledged)
// it drops out of the mirror. The Instance Actor must be told (NativeAlarmDropped)
// so its _latestAlarmEvents map does not retain a stale (Normal) entry forever —
// otherwise a source that mints a fresh SourceReference per occurrence leaks one
// entry per condition the instance has ever seen.
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var t0 = DateTimeOffset.UtcNow;
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
// Resolved: inactive AND acknowledged → return-to-normal emitted, then dropped.
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Clear,
new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0), t0.AddSeconds(5))));
instance.ExpectMsg<AlarmStateChanged>(m => m.SourceReference == "T01.Hi" && m.State == AlarmState.Normal);
instance.ExpectMsg<NativeAlarmDropped>(m => m.SourceReference == "T01.Hi");
}
void IDisposable.Dispose()
{
Shutdown();
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Persistence;
@@ -140,6 +141,42 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
// Upsert should not throw
}
// ── DeploymentManager-025 / SiteRuntime-031: central-only notif/SMTP purge ──
[Fact]
public async Task PurgeCentralOnlyNotificationConfig_RemovesPersistedNotificationListsAndSmtpRows()
{
// Simulate a pre-fix build that already shipped a notification list and an
// SMTP config (with a plaintext password) to the site.
await _storage.StoreNotificationListAsync("Ops Team", ["ops@example.com"]);
await _storage.StoreSmtpConfigurationAsync(
"smtp.example.com:587", "smtp.example.com", 587, "BasicAuth",
"noreply@example.com", "smtpuser", "PLAINTEXT-SECRET", null);
var repo = new SiteNotificationRepository(_storage);
Assert.NotEmpty(await repo.GetAllNotificationListsAsync());
Assert.NotEmpty(await repo.GetAllSmtpConfigurationsAsync());
// The fix: every artifact apply/deploy purges these central-only rows.
await _storage.PurgeCentralOnlyNotificationConfigAsync();
// Both tables are now empty — the plaintext SMTP credential is gone.
Assert.Empty(await repo.GetAllNotificationListsAsync());
Assert.Empty(await repo.GetAllSmtpConfigurationsAsync());
}
[Fact]
public async Task PurgeCentralOnlyNotificationConfig_IsIdempotent_OnEmptyTables()
{
// No rows present — purge must not throw and must leave the tables empty.
await _storage.PurgeCentralOnlyNotificationConfigAsync();
await _storage.PurgeCentralOnlyNotificationConfigAsync();
var repo = new SiteNotificationRepository(_storage);
Assert.Empty(await repo.GetAllNotificationListsAsync());
Assert.Empty(await repo.GetAllSmtpConfigurationsAsync());
}
// ── Schema includes all WP-33 tables ──
[Fact]
@@ -131,6 +131,84 @@ public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable
Assert.Equal(1, ReadQueueDepthGauge());
}
/// <summary>
/// StoreAndForward-025: after a graceful <see cref="StoreAndForwardService.StopAsync"/>
/// the service must deregister its queue-depth provider from the process-global gauge
/// slot, so the gauge stops reporting the stopped instance's (now-frozen) depth and the
/// provider closure no longer pins the dead service. With the provider cleared the gauge
/// falls back to 0.
/// </summary>
[Fact]
public async Task StopAsync_ClearsQueueDepthProvider_GaugeFallsBackToZero()
{
var fresh = new StoreAndForwardService(
_storage,
new StoreAndForwardOptions { RetryTimerInterval = TimeSpan.FromMinutes(10) },
NullLogger<StoreAndForwardService>.Instance);
// Register a Pending row this instance owns, then start so the instance registers
// its provider and seeds the cached count to 1 → gauge reports 1.
await _storage.EnqueueAsync(new StoreAndForwardMessage
{
Id = Guid.NewGuid().ToString("N"),
Category = StoreAndForwardCategory.ExternalSystem,
Target = "api",
PayloadJson = "{}",
Status = StoreAndForwardMessageStatus.Pending,
CreatedAt = DateTimeOffset.UtcNow,
MaxRetries = 3
});
await fresh.StartAsync();
Assert.Equal(1, ReadQueueDepthGauge());
// Graceful stop must deregister the provider; the gauge falls back to 0 rather
// than reporting this dead instance's frozen depth of 1.
await fresh.StopAsync();
Assert.Equal(0, ReadQueueDepthGauge());
}
/// <summary>
/// StoreAndForward-025 (compare-and-clear): when a newer instance has already
/// registered its provider into the process-global slot, a late
/// <see cref="StoreAndForwardService.StopAsync"/> of an older instance must NOT clear
/// the slot — the identity-checked clear only removes the slot when it still holds the
/// stopping instance's own delegate. After the late stop the gauge must still report
/// the newer instance's depth, not 0.
/// </summary>
[Fact]
public async Task StopAsync_DoesNotClobberNewerInstanceProvider()
{
// Old instance: starts over an empty store, registers its provider (gauge → 0),
// then takes a single buffered message so it would report 1 if it stayed live.
var older = new StoreAndForwardService(
_storage,
new StoreAndForwardOptions { RetryTimerInterval = TimeSpan.FromMinutes(10) },
NullLogger<StoreAndForwardService>.Instance);
await older.StartAsync();
older.TestOnly_IncrementBufferedCount(); // older's depth would be 1
Assert.Equal(1, ReadQueueDepthGauge());
// New instance: starts and re-registers into the same global slot, winning it.
// It seeds from the (empty) store and stands in two buffered messages → depth 2.
var newer = new StoreAndForwardService(
_storage,
new StoreAndForwardOptions { RetryTimerInterval = TimeSpan.FromMinutes(10) },
NullLogger<StoreAndForwardService>.Instance);
await newer.StartAsync();
newer.TestOnly_IncrementBufferedCount();
newer.TestOnly_IncrementBufferedCount();
Assert.Equal(2, ReadQueueDepthGauge());
// Late stop of the OLDER instance: compare-and-clear must fail the identity check
// (the slot now holds the newer instance's delegate), so the newer provider stays.
await older.StopAsync();
Assert.Equal(2, ReadQueueDepthGauge());
// Cleanup: stop the newer instance, which legitimately clears its own provider.
await newer.StopAsync();
Assert.Equal(0, ReadQueueDepthGauge());
}
[Fact]
public async Task Gauge_SeedsFromExistingPendingRows_OnStart()
{
@@ -30,7 +30,8 @@ public class TemplateFolderServiceTests
Assert.Equal("Dev", result.Value.Name);
Assert.Null(result.Value.ParentFolderId);
_repoMock.Verify(r => r.AddFolderAsync(It.IsAny<TemplateFolder>(), It.IsAny<CancellationToken>()), Times.Once);
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
// Two saves: the folder entity, then the staged audit row (TemplateEngine-024).
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Fact]
@@ -485,9 +486,120 @@ public class TemplateFolderServiceTests
// Both swapped siblings persisted.
_repoMock.Verify(r => r.UpdateFolderAsync(a, It.IsAny<CancellationToken>()), Times.Once);
_repoMock.Verify(r => r.UpdateFolderAsync(b, It.IsAny<CancellationToken>()), Times.Once);
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
// Two saves: the swapped siblings, then the staged audit row (TemplateEngine-024).
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Exactly(2));
// Audit entry written (mirrors Move/Rename audit assertions).
_auditMock.Verify(au => au.LogAsync("admin", "Reorder", "TemplateFolder", "2", "B",
It.IsAny<object?>(), It.IsAny<CancellationToken>()), Times.Once);
}
// ========================================================================
// TemplateEngine-024 — each folder mutator must SaveChanges *after* LogAsync
// so the staged audit row is persisted (and not discarded when the scope is
// disposed). Verified by tracking call order across the mutators.
// ========================================================================
[Fact]
public async Task CreateFolder_PersistsAuditRow_SaveFollowsLog()
{
AssertAuditRowPersisted(await BuildOrderTracker(async () =>
await _sut.CreateFolderAsync("Dev", null, "admin"),
seed: () => _repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder>())));
}
[Fact]
public async Task RenameFolder_PersistsAuditRow_SaveFollowsLog()
{
var folder = new TemplateFolder("Old") { Id = 1, ParentFolderId = null };
AssertAuditRowPersisted(await BuildOrderTracker(async () =>
await _sut.RenameFolderAsync(1, "New", "admin"),
seed: () =>
{
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { folder });
}));
}
[Fact]
public async Task MoveFolder_PersistsAuditRow_SaveFollowsLog()
{
var f1 = new TemplateFolder("A") { Id = 1, ParentFolderId = null };
var f2 = new TemplateFolder("B") { Id = 2, ParentFolderId = null };
AssertAuditRowPersisted(await BuildOrderTracker(async () =>
await _sut.MoveFolderAsync(1, 2, "admin"),
seed: () =>
{
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f1);
_repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(f2);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { f1, f2 });
}));
}
[Fact]
public async Task ReorderFolder_PersistsAuditRow_SaveFollowsLog()
{
var a = new TemplateFolder("A") { Id = 1, ParentFolderId = null, SortOrder = 0 };
var b = new TemplateFolder("B") { Id = 2, ParentFolderId = null, SortOrder = 1 };
AssertAuditRowPersisted(await BuildOrderTracker(async () =>
await _sut.ReorderFolderAsync(2, ReorderDirection.Up, "admin"),
seed: () =>
{
_repoMock.Setup(r => r.GetFolderByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(b);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { a, b });
}));
}
[Fact]
public async Task DeleteFolder_PersistsAuditRow_SaveFollowsLog()
{
var f = new TemplateFolder("Empty") { Id = 1 };
AssertAuditRowPersisted(await BuildOrderTracker(async () =>
await _sut.DeleteFolderAsync(1, "admin"),
seed: () =>
{
_repoMock.Setup(r => r.GetFolderByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(f);
_repoMock.Setup(r => r.GetAllFoldersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<TemplateFolder> { f });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>());
}));
}
/// <summary>
/// Records the interleaving of <c>LogAsync</c> and <c>SaveChangesAsync</c> calls
/// while invoking a mutator, returning the ordered list of call markers
/// ("save" / "log") observed during the operation.
/// </summary>
private async Task<List<string>> BuildOrderTracker(Func<Task> act, Action seed)
{
seed();
var calls = new List<string>();
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Callback(() => calls.Add("save"))
.ReturnsAsync(1);
_auditMock.Setup(a => a.LogAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<object?>(), It.IsAny<CancellationToken>()))
.Callback(() => calls.Add("log"))
.Returns(Task.CompletedTask);
await act();
return calls;
}
/// <summary>
/// Asserts the mutator logged an audit entry and then issued a
/// <c>SaveChangesAsync</c> after it — proving the staged audit row is
/// persisted rather than discarded (TemplateEngine-024).
/// </summary>
private static void AssertAuditRowPersisted(List<string> calls)
{
var logIndex = calls.IndexOf("log");
Assert.True(logIndex >= 0, "Expected an audit LogAsync call.");
// There must be at least one SaveChangesAsync recorded *after* the log call.
Assert.Contains("save", calls.Skip(logIndex + 1));
}
}
@@ -840,24 +840,6 @@ public class SemanticValidatorTests
Assert.Contains(targets, t => t.TargetName == "Script2" && !t.IsShared && t.ArgumentCount == 0);
}
[Fact]
public void ParseParameterDefinitions_ValidJson_ReturnsList()
{
var json = "[{\"name\":\"a\",\"type\":\"Int32\"},{\"name\":\"b\",\"type\":\"String\"}]";
var result = SemanticValidator.ParseParameterDefinitions(json);
Assert.Equal(2, result.Count);
Assert.Equal("Int32", result[0]);
Assert.Equal("String", result[1]);
}
[Fact]
public void ParseParameterDefinitions_NullOrEmpty_ReturnsEmpty()
{
Assert.Empty(SemanticValidator.ParseParameterDefinitions(null));
Assert.Empty(SemanticValidator.ParseParameterDefinitions(""));
}
// ── HiLo validation ─────────────────────────────────────────────────────
private static FlattenedConfiguration HiLoConfig(string attrName, string dataType, string triggerJson) =>