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:
+51
@@ -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()
|
||||
{
|
||||
|
||||
+75
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
+23
-7
@@ -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&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
-3
@@ -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()
|
||||
{
|
||||
|
||||
+56
@@ -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()
|
||||
{
|
||||
|
||||
+114
-2
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user