merge: integrate WaitAsync/M5-audit (parallel session) with galaxy array-write + inbound-timeout fixes
This commit is contained in:
+6
@@ -362,6 +362,9 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -399,5 +402,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,14 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) =>
|
||||
_inner.SwitchOutPartitionAsync(monthBoundary, ct);
|
||||
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, CancellationToken ct = default) =>
|
||||
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, ct);
|
||||
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||
_inner.BackfillSourceNodeAsync(sentinel, before, batchSize, ct);
|
||||
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
|
||||
|
||||
@@ -51,6 +51,12 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
public DateTime? ThrowOnBoundary { get; set; }
|
||||
public Exception? BoundaryException { get; set; }
|
||||
|
||||
// M5.5 (T3): records every per-channel purge call as
|
||||
// (channel, threshold, batchSize) so tests can assert which channels the
|
||||
// actor chose to purge and with what window.
|
||||
public List<(string Channel, DateTime Threshold, int BatchSize)> ChannelPurges { get; } = new();
|
||||
public Func<string, long> RowsPerChannel { get; set; } = _ => 0L;
|
||||
|
||||
// The actor enumerator returns whichever list is configured here.
|
||||
// Mutating this between ticks lets tests simulate "no longer
|
||||
// eligible" boundaries on the second tick.
|
||||
@@ -80,6 +86,17 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
return Task.FromResult<IReadOnlyList<DateTime>>(Boundaries.ToArray());
|
||||
}
|
||||
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, CancellationToken ct = default)
|
||||
{
|
||||
ChannelPurges.Add((channel, threshold, batchSize));
|
||||
return Task.FromResult(RowsPerChannel(channel));
|
||||
}
|
||||
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||
@@ -268,21 +285,32 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Today is ~2026-05-20 per the test environment. With RetentionDays =
|
||||
// 60 the actor computes threshold ≈ 2026-03-21:
|
||||
// * Jan partition (MAX = Jan 15) → older than threshold → PURGED
|
||||
// * Apr partition (MAX = Apr 15) → newer than threshold → KEPT
|
||||
// Seeds two rows within the defined pf_AuditLog_Month partition range (Jan 2026 –
|
||||
// Dec 2027). RetentionDays is computed dynamically so the purge threshold always
|
||||
// anchors near 2026-01-20, keeping the test date-independent:
|
||||
// old row = Jan 15 2026 → Jan 15 < threshold ~Jan 20 → partition PURGED
|
||||
// kept row = Apr 15 2026 → Apr 15 > threshold ~Jan 20 → partition KEPT
|
||||
//
|
||||
// Using a fixed thresholdAnchor rather than "N months ago" avoids the problem
|
||||
// of relative seeds landing before 2026-01-01 (the catch-all partition that
|
||||
// GetPartitionBoundariesOlderThanAsync never returns).
|
||||
var thresholdAnchor = new DateTime(2026, 1, 20, 0, 0, 0, DateTimeKind.Utc);
|
||||
var retentionDays = (int)(DateTime.UtcNow - thresholdAnchor).TotalDays + 1;
|
||||
|
||||
var oldOccurred = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc);
|
||||
var keptOccurred = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var siteId = "purge-e2e-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
var janEvt = ScadaBridgeAuditEventFactory.Create(
|
||||
var oldEvt = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
occurredAtUtc: oldOccurred,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
sourceSiteId: siteId);
|
||||
var aprEvt = ScadaBridgeAuditEventFactory.Create(
|
||||
var keptEvt = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
occurredAtUtc: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
occurredAtUtc: keptOccurred,
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
@@ -291,8 +319,8 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
await using (var seedContext = CreateMsSqlContext())
|
||||
{
|
||||
var seedRepo = new AuditLogRepository(seedContext);
|
||||
await seedRepo.InsertIfNotExistsAsync(janEvt);
|
||||
await seedRepo.InsertIfNotExistsAsync(aprEvt);
|
||||
await seedRepo.InsertIfNotExistsAsync(oldEvt);
|
||||
await seedRepo.InsertIfNotExistsAsync(keptEvt);
|
||||
}
|
||||
|
||||
// Wire the actor's DI scope to the real repository against the
|
||||
@@ -306,7 +334,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var auditOptions = new AuditLogOptions { RetentionDays = 60 };
|
||||
var auditOptions = new AuditLogOptions { RetentionDays = retentionDays };
|
||||
var purgeOptions = new AuditLogPurgeOptions
|
||||
{
|
||||
IntervalHours = 24,
|
||||
@@ -320,13 +348,9 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
Options.Create(auditOptions),
|
||||
NullLogger<AuditLogPurgeActor>.Instance)));
|
||||
|
||||
// The probe receives one AuditLogPurgedEvent per partition the actor
|
||||
// purges per tick — other test runs that share the fixture DB may
|
||||
// also leave behind eligible partitions, but this test creates its
|
||||
// own fixture DB so the Jan-2026 partition is the only eligible one.
|
||||
// Use FishForMessage to filter just in case, with a generous timeout
|
||||
// because the real drop-and-rebuild dance against MSSQL routinely
|
||||
// takes a couple of seconds on a busy dev container.
|
||||
// Fish for the Jan-2026 partition boundary — the only eligible one in this
|
||||
// fixture DB. The generous timeout covers the real drop-and-rebuild dance
|
||||
// against MSSQL which routinely takes a couple of seconds on a busy dev container.
|
||||
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var matched = probe.FishForMessage<AuditLogPurgedEvent>(
|
||||
isMessage: m => m.MonthBoundary == janBoundary,
|
||||
@@ -342,8 +366,8 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.DoesNotContain(rows, r => r.EventId == janEvt.EventId);
|
||||
Assert.Contains(rows, r => r.EventId == aprEvt.EventId);
|
||||
Assert.DoesNotContain(rows, r => r.EventId == oldEvt.EventId);
|
||||
Assert.Contains(rows, r => r.EventId == keptEvt.EventId);
|
||||
}
|
||||
|
||||
private ScadaBridgeDbContext CreateMsSqlContext() =>
|
||||
@@ -381,4 +405,90 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
Math.Abs((threshold - expected).TotalMinutes) < 1.0,
|
||||
$"threshold {threshold:o} should be within 1 minute of {expected:o}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 8. PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge (M5.5 T3)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge()
|
||||
{
|
||||
// ApiOutbound has a 30-day override under a 365-day global window — strictly
|
||||
// shorter, so the actor must run a per-channel purge with a threshold of
|
||||
// ~today-30d and the configured batch size.
|
||||
var repo = new RecordingRepo { Boundaries = new List<DateTime>() };
|
||||
var purgeOptions = FastTickOptions();
|
||||
purgeOptions.ChannelPurgeBatchSizeConfigured = 1234;
|
||||
|
||||
// Build the options OUTSIDE the Props expression tree — a collection/dictionary
|
||||
// initializer is not legal inside an expression-tree lambda (CS8074).
|
||||
var auditOptions = Options.Create(new AuditLogOptions
|
||||
{
|
||||
RetentionDays = 365,
|
||||
PerChannelRetentionDays = new Dictionary<string, int> { ["ApiOutbound"] = 30 },
|
||||
});
|
||||
var purgeOptionsWrapped = Options.Create(purgeOptions);
|
||||
|
||||
var sp = BuildScopedProvider(repo);
|
||||
Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
|
||||
sp,
|
||||
purgeOptionsWrapped,
|
||||
auditOptions,
|
||||
NullLogger<AuditLogPurgeActor>.Instance)));
|
||||
|
||||
AwaitAssert(
|
||||
() => Assert.Contains(repo.ChannelPurges, p => p.Channel == "ApiOutbound"),
|
||||
duration: TimeSpan.FromSeconds(3),
|
||||
interval: TimeSpan.FromMilliseconds(50));
|
||||
|
||||
var purge = repo.ChannelPurges.First(p => p.Channel == "ApiOutbound");
|
||||
Assert.Equal(1234, purge.BatchSize);
|
||||
|
||||
var expected = DateTime.UtcNow - TimeSpan.FromDays(30);
|
||||
Assert.True(
|
||||
Math.Abs((purge.Threshold - expected).TotalMinutes) < 1.0,
|
||||
$"channel threshold {purge.Threshold:o} should be within 1 minute of {expected:o}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 9. PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge (M5.5 T3)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge()
|
||||
{
|
||||
// DbOutbound = 365 (== global) and Notification = 400 (> global, validator would
|
||||
// normally reject this but the actor must defensively skip it too). Neither is
|
||||
// SHORTER than the global window, so the actor must NOT issue a channel purge —
|
||||
// the global partition switch-out already governs those rows.
|
||||
var repo = new RecordingRepo { Boundaries = new List<DateTime>() };
|
||||
|
||||
// Build the options OUTSIDE the Props expression tree (CS8074).
|
||||
var auditOptions = Options.Create(new AuditLogOptions
|
||||
{
|
||||
RetentionDays = 365,
|
||||
PerChannelRetentionDays = new Dictionary<string, int>
|
||||
{
|
||||
["DbOutbound"] = 365,
|
||||
["Notification"] = 400,
|
||||
},
|
||||
});
|
||||
var purgeOptions = Options.Create(FastTickOptions());
|
||||
|
||||
var sp = BuildScopedProvider(repo);
|
||||
Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
|
||||
sp,
|
||||
purgeOptions,
|
||||
auditOptions,
|
||||
NullLogger<AuditLogPurgeActor>.Instance)));
|
||||
|
||||
// Wait for at least one tick (visible via the enumerator call), then assert no
|
||||
// channel purge was issued.
|
||||
AwaitAssert(
|
||||
() => Assert.True(repo.ThresholdQueries.Count >= 1),
|
||||
duration: TimeSpan.FromSeconds(3),
|
||||
interval: TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.Empty(repo.ChannelPurges);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using IAuditInboundCeilingHitsCounter = ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
|
||||
@@ -43,6 +44,12 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
|
||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||
@@ -163,6 +170,69 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
var snapshot = new AuditCentralHealthSnapshot();
|
||||
Assert.Equal(0, snapshot.CentralAuditWriteFailures);
|
||||
Assert.Equal(0, snapshot.AuditRedactionFailure);
|
||||
Assert.Equal(0, snapshot.AuditInboundCeilingHits);
|
||||
Assert.Empty(snapshot.SiteAuditTelemetryStalled);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// M5.3 (T7) AuditInboundCeilingHits counter
|
||||
// AuditCentralHealthSnapshot implements IAuditInboundCeilingHitsCounter.
|
||||
// Incrementing through the interface surface is reflected on the snapshot.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AuditInboundCeilingHits_StartsAtZero()
|
||||
{
|
||||
var snapshot = new AuditCentralHealthSnapshot();
|
||||
Assert.Equal(0, snapshot.AuditInboundCeilingHits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditInboundCeilingHits_IncrementedThroughInterface_ReflectedOnSnapshot()
|
||||
{
|
||||
var snapshot = new AuditCentralHealthSnapshot();
|
||||
var counter = (IAuditInboundCeilingHitsCounter)snapshot;
|
||||
|
||||
counter.Increment();
|
||||
counter.Increment();
|
||||
counter.Increment();
|
||||
|
||||
Assert.Equal(3, snapshot.AuditInboundCeilingHits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditInboundCeilingHits_IsThreadSafe()
|
||||
{
|
||||
// Interlocked increment must produce the correct count under concurrent
|
||||
// increments — same shape as the existing counter tests.
|
||||
var snapshot = new AuditCentralHealthSnapshot();
|
||||
var counter = (IAuditInboundCeilingHitsCounter)snapshot;
|
||||
const int incrementCount = 1000;
|
||||
|
||||
Parallel.For(0, incrementCount, _ => counter.Increment());
|
||||
|
||||
Assert.Equal(incrementCount, snapshot.AuditInboundCeilingHits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditInboundCeilingHits_IsIndependentOfOtherCounters()
|
||||
{
|
||||
// Ceiling-hits increments must not cross-contaminate the other counters
|
||||
// and vice versa — each Interlocked field is independent.
|
||||
var snapshot = new AuditCentralHealthSnapshot();
|
||||
var ceilingCounter = (IAuditInboundCeilingHitsCounter)snapshot;
|
||||
var writeCounter = (ICentralAuditWriteFailureCounter)snapshot;
|
||||
var redactCounter = (ZB.MOM.WW.ScadaBridge.AuditLog.Payload.IAuditRedactionFailureCounter)snapshot;
|
||||
|
||||
ceilingCounter.Increment();
|
||||
ceilingCounter.Increment();
|
||||
writeCounter.Increment();
|
||||
redactCounter.Increment();
|
||||
redactCounter.Increment();
|
||||
redactCounter.Increment();
|
||||
|
||||
Assert.Equal(2, snapshot.AuditInboundCeilingHits);
|
||||
Assert.Equal(1, snapshot.CentralAuditWriteFailures);
|
||||
Assert.Equal(3, snapshot.AuditRedactionFailure);
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -89,6 +89,14 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||
Task.FromResult(0L);
|
||||
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||
|
||||
+103
@@ -50,4 +50,107 @@ public class AuditLogOptionsValidatorTests
|
||||
result.Failures!,
|
||||
f => f.Contains(nameof(AuditLogOptions.InboundMaxBytes), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// M5.5 (T3) per-channel retention overrides
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Validate_PerChannelRetention_ShorterThanGlobal_Passes()
|
||||
{
|
||||
// A per-channel window strictly shorter than the global window is the
|
||||
// sanctioned case — the purge actor expires those rows earlier via the
|
||||
// maintenance-path row DELETE.
|
||||
var validator = new AuditLogOptionsValidator();
|
||||
var opts = new AuditLogOptions
|
||||
{
|
||||
RetentionDays = 365,
|
||||
PerChannelRetentionDays = new Dictionary<string, int>
|
||||
{
|
||||
["ApiOutbound"] = 90,
|
||||
["Notification"] = 30, // floor (MinRetentionDays)
|
||||
},
|
||||
};
|
||||
|
||||
Assert.True(validator.Validate(null, opts).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PerChannelRetention_EqualToGlobal_Passes()
|
||||
{
|
||||
// Equal to global is allowed (the bound is [Min, RetentionDays] inclusive);
|
||||
// the purge actor simply treats it as a no-op since it is not SHORTER.
|
||||
var validator = new AuditLogOptionsValidator();
|
||||
var opts = new AuditLogOptions
|
||||
{
|
||||
RetentionDays = 200,
|
||||
PerChannelRetentionDays = new Dictionary<string, int> { ["DbOutbound"] = 200 },
|
||||
};
|
||||
|
||||
Assert.True(validator.Validate(null, opts).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PerChannelRetention_LongerThanGlobal_Fails()
|
||||
{
|
||||
// A per-channel window LONGER than the global window is meaningless under
|
||||
// month-partition switch-out (governed by the global window) and is rejected.
|
||||
var validator = new AuditLogOptionsValidator();
|
||||
var opts = new AuditLogOptions
|
||||
{
|
||||
RetentionDays = 100,
|
||||
PerChannelRetentionDays = new Dictionary<string, int> { ["ApiInbound"] = 200 },
|
||||
};
|
||||
|
||||
var result = validator.Validate(null, opts);
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains(nameof(AuditLogOptions.PerChannelRetentionDays), StringComparison.Ordinal)
|
||||
&& f.Contains("ApiInbound", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PerChannelRetention_BelowMinimum_Fails()
|
||||
{
|
||||
var validator = new AuditLogOptionsValidator();
|
||||
var opts = new AuditLogOptions
|
||||
{
|
||||
RetentionDays = 365,
|
||||
PerChannelRetentionDays = new Dictionary<string, int> { ["ApiOutbound"] = 29 },
|
||||
};
|
||||
|
||||
var result = validator.Validate(null, opts);
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains(nameof(AuditLogOptions.PerChannelRetentionDays), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PerChannelRetention_UnknownChannelKey_Fails()
|
||||
{
|
||||
// Keys must be recognized AuditChannel names; a typo / unknown key is rejected
|
||||
// rather than silently ignored so a misconfiguration surfaces at boot.
|
||||
var validator = new AuditLogOptionsValidator();
|
||||
var opts = new AuditLogOptions
|
||||
{
|
||||
RetentionDays = 365,
|
||||
PerChannelRetentionDays = new Dictionary<string, int> { ["NotAChannel"] = 90 },
|
||||
};
|
||||
|
||||
var result = validator.Validate(null, opts);
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains("NotAChannel", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PerChannelRetention_DefaultEmpty_Passes()
|
||||
{
|
||||
// The default (no overrides) must pass — this is the common case.
|
||||
var validator = new AuditLogOptionsValidator();
|
||||
Assert.True(validator.Validate(null, new AuditLogOptions()).Succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -623,5 +623,11 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
|
||||
public Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
|
||||
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
// WaitForAttribute is not part of this fixture's routed-Call audit scenario;
|
||||
// mirror the other non-Call methods (unexercised here).
|
||||
public Task<RouteToWaitForAttributeResponse> RouteToWaitForAttributeAsync(
|
||||
string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,19 +67,25 @@ public class PartitionPurgeTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||
SqlConnection conn,
|
||||
Guid eventId,
|
||||
DateTime occurredAtUtc,
|
||||
string siteId)
|
||||
string siteId,
|
||||
string channel = "ApiOutbound",
|
||||
string kind = "ApiCall")
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
// C5 (Task 2.5): dbo.AuditLog is now the 10 canonical columns + DetailsJson;
|
||||
// the ScadaBridge domain fields (channel/kind/status/sourceSiteId) ride in
|
||||
// DetailsJson and the SourceSiteId/Kind/Status computed columns auto-derive.
|
||||
// Action = "{channel}.{kind}", Category = channel name, Outcome = Success.
|
||||
// The channel/kind are parameterized so the M5.5 per-channel purge test can
|
||||
// seed multiple channels into the same partition.
|
||||
cmd.CommandText = @"
|
||||
INSERT INTO dbo.AuditLog
|
||||
(EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson)
|
||||
VALUES
|
||||
(@EventId, @OccurredAtUtc, NULL, 'ApiOutbound.ApiCall', 'Success', 'ApiOutbound', NULL, NULL, NULL,
|
||||
(@EventId, @OccurredAtUtc, NULL, @Action, 'Success', @Category, NULL, NULL, NULL,
|
||||
@DetailsJson);";
|
||||
cmd.Parameters.Add("@Action", System.Data.SqlDbType.VarChar, 64).Value = $"{channel}.{kind}";
|
||||
cmd.Parameters.Add("@Category", System.Data.SqlDbType.VarChar, 32).Value = channel;
|
||||
cmd.Parameters.Add("@EventId", System.Data.SqlDbType.UniqueIdentifier).Value = eventId;
|
||||
// SqlDbType.DateTime2 with explicit Scale 7 matches the
|
||||
// OccurredAtUtc column shape (datetime2(7)) and avoids the implicit
|
||||
@@ -97,7 +103,7 @@ VALUES
|
||||
// the computed SourceSiteId column the verify queries scope on. payloadTruncated
|
||||
// is always present (the codec always writes the bool).
|
||||
var detailsJson =
|
||||
"{\"channel\":\"ApiOutbound\",\"kind\":\"ApiCall\",\"status\":\"Delivered\"," +
|
||||
"{\"channel\":\"" + channel + "\",\"kind\":\"" + kind + "\",\"status\":\"Delivered\"," +
|
||||
"\"sourceSiteId\":\"" + siteId + "\",\"payloadTruncated\":false}";
|
||||
cmd.Parameters.Add("@DetailsJson", System.Data.SqlDbType.NVarChar, -1).Value = detailsJson;
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
@@ -134,10 +140,49 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
NullLogger<AuditLogPurgeActor>.Instance)));
|
||||
}
|
||||
|
||||
private static (DateTime Jan, DateTime Feb, DateTime Mar) SeedOccurredAt() => (
|
||||
new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 2, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc));
|
||||
/// <summary>
|
||||
/// Returns three seed timestamps and a computed <c>RetentionDays</c> value that
|
||||
/// keep the purge-intent date-independent regardless of when the test runs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The partition function <c>pf_AuditLog_Month</c> has explicit boundaries only
|
||||
/// for 2026-01-01 through 2027-12-01. Rows outside that range land in the
|
||||
/// catch-all partitions which have no <c>partition_range_values</c> entry and are
|
||||
/// therefore never returned by
|
||||
/// <see cref="IAuditLogRepository.GetPartitionBoundariesOlderThanAsync"/>.
|
||||
/// All three seeds must therefore fall inside the defined boundary range.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// To remain date-independent the test computes <c>RetentionDays</c> dynamically
|
||||
/// so the purge threshold always lands near <b>2026-01-20</b>:
|
||||
/// <code>
|
||||
/// RetentionDays = (int)(DateTime.UtcNow - new DateTime(2026, 1, 20, UTC)).TotalDays + 1
|
||||
/// </code>
|
||||
/// This gives:
|
||||
/// <list type="bullet">
|
||||
/// <item>Jan 15 2026 row → Jan 15 < Jan 20 threshold → <b>PURGED</b>.</item>
|
||||
/// <item>Apr 15 / Jun 15 2026 rows → both after Jan 20 → <b>KEPT</b>.</item>
|
||||
/// </list>
|
||||
/// The threshold anchors to a fixed calendar point (~Jan 20 2026), so the
|
||||
/// relationship holds for any future run date as long as the explicit partition
|
||||
/// boundaries remain.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static (DateTime Old, DateTime Mid, DateTime Recent, int RetentionDays) SeedOccurredAt()
|
||||
{
|
||||
// Anchor the threshold midway through January 2026 — strictly after the
|
||||
// "old" seed (Jan 15) and strictly before the "mid" seed (Apr 15).
|
||||
var thresholdAnchor = new DateTime(2026, 1, 20, 0, 0, 0, DateTimeKind.Utc);
|
||||
var retentionDays = (int)(DateTime.UtcNow - thresholdAnchor).TotalDays + 1;
|
||||
|
||||
return (
|
||||
Old: new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), // in Jan-2026 partition → PURGED
|
||||
Mid: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), // in Apr-2026 partition → KEPT
|
||||
Recent: new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc), // in Jun-2026 partition → KEPT
|
||||
RetentionDays: retentionDays
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 1. EndToEnd_OldestPartition_PurgedViaActor_NewerKept
|
||||
@@ -148,24 +193,23 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Test date is ~2026-05-20 per environment. We want a threshold that
|
||||
// sits strictly between Jan 15 (the Jan partition's MAX) and Feb 15
|
||||
// (the Feb partition's MAX) so only the Jan-2026 partition is
|
||||
// eligible for purge. RetentionDays = 100 gives a threshold of
|
||||
// ~2026-02-09 — Jan 15 is older (purged), Feb 15 and Mar 15 are
|
||||
// newer (kept). The window between Jan 15 and Feb 15 is wide enough
|
||||
// (~30 days) to tolerate any plausible test-clock drift in CI.
|
||||
// Seeds three rows in distinct calendar months. RetentionDays is computed
|
||||
// dynamically so the purge threshold always lands near 2026-01-20 (see
|
||||
// SeedOccurredAt() for the full rationale):
|
||||
// Old = Jan 15 2026 → Jan 15 < threshold ~Jan 20 → PURGED
|
||||
// Mid = Apr 15 2026 → Apr 15 > threshold ~Jan 20 → KEPT
|
||||
// Recent = Jun 15 2026 → Jun 15 > threshold ~Jan 20 → KEPT
|
||||
var siteId = "purge-e2e-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
var janEventId = Guid.NewGuid();
|
||||
var febEventId = Guid.NewGuid();
|
||||
var marEventId = Guid.NewGuid();
|
||||
var (janOccurred, febOccurred, marOccurred) = SeedOccurredAt();
|
||||
var oldEventId = Guid.NewGuid();
|
||||
var midEventId = Guid.NewGuid();
|
||||
var recentEventId = Guid.NewGuid();
|
||||
var (oldOccurred, midOccurred, recentOccurred, retentionDays) = SeedOccurredAt();
|
||||
|
||||
await using (var seedConn = _fixture.OpenConnection())
|
||||
{
|
||||
await DirectInsertAsync(seedConn, janEventId, janOccurred, siteId);
|
||||
await DirectInsertAsync(seedConn, febEventId, febOccurred, siteId);
|
||||
await DirectInsertAsync(seedConn, marEventId, marOccurred, siteId);
|
||||
await DirectInsertAsync(seedConn, oldEventId, oldOccurred, siteId);
|
||||
await DirectInsertAsync(seedConn, midEventId, midOccurred, siteId);
|
||||
await DirectInsertAsync(seedConn, recentEventId, recentOccurred, siteId);
|
||||
}
|
||||
|
||||
// Wire the actor with a real EF context against the fixture DB.
|
||||
@@ -184,15 +228,11 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
IntervalHours = 24,
|
||||
IntervalOverride = TimeSpan.FromMilliseconds(100),
|
||||
};
|
||||
var auditOptions = new AuditLogOptions { RetentionDays = 100 };
|
||||
var auditOptions = new AuditLogOptions { RetentionDays = retentionDays };
|
||||
|
||||
CreateActor(sp, purgeOptions, auditOptions);
|
||||
|
||||
// Wait for the actor's tick to purge the Jan-2026 partition.
|
||||
// Concurrent test runs against the same fixture might also create
|
||||
// eligible partitions, but each test class owns its own fixture DB
|
||||
// (MsSqlMigrationFixture seeds a guid-named DB per class), so the
|
||||
// Jan-2026 boundary is the only one this test can have produced.
|
||||
// The Jan-2026 partition boundary is the only eligible one in this fixture DB.
|
||||
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var matched = probe.FishForMessage<AuditLogPurgedEvent>(
|
||||
isMessage: m => m.MonthBoundary == janBoundary,
|
||||
@@ -200,9 +240,7 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
Assert.True(matched.RowsDeleted >= 1,
|
||||
$"Expected RowsDeleted >= 1 for Jan-2026 boundary; got {matched.RowsDeleted}.");
|
||||
|
||||
// Allow a brief settle in case the actor is mid-tick on Feb/Mar
|
||||
// (it shouldn't be, since RetentionDays = 90 means only Jan is
|
||||
// eligible, but the actor MAY re-enumerate quickly while we read).
|
||||
// Allow a brief settle in case the actor re-enumerates quickly.
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
await using var verify = CreateContext();
|
||||
@@ -210,11 +248,10 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
// Jan removed; Feb + Mar untouched. Because the test owns the site
|
||||
// id and the fixture DB, exact set membership is observable.
|
||||
Assert.DoesNotContain(rows, r => r.EventId == janEventId);
|
||||
Assert.Contains(rows, r => r.EventId == febEventId);
|
||||
Assert.Contains(rows, r => r.EventId == marEventId);
|
||||
// Old (Jan) removed; Mid (Apr) + Recent (Jun) untouched.
|
||||
Assert.DoesNotContain(rows, r => r.EventId == oldEventId);
|
||||
Assert.Contains(rows, r => r.EventId == midEventId);
|
||||
Assert.Contains(rows, r => r.EventId == recentEventId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -226,20 +263,19 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Same shape as test 1 — purge the Jan-2026 partition and then
|
||||
// assert the UX_AuditLog_EventId index is still present. The
|
||||
// drop-and-rebuild dance briefly removes it inside its transaction
|
||||
// (the SWITCH PARTITION step requires the non-aligned unique index
|
||||
// to be absent), but step 5 rebuilds it before committing. Sanity-
|
||||
// checking the post-COMMIT shape here documents the invariant in an
|
||||
// assertable way.
|
||||
// Same shape as test 1 — purge the Jan-2026 partition and then assert the
|
||||
// UX_AuditLog_EventId index is still present. RetentionDays is computed
|
||||
// dynamically so the threshold always lands near 2026-01-20 (see SeedOccurredAt()).
|
||||
// The drop-and-rebuild dance briefly removes the index inside its transaction
|
||||
// (the SWITCH PARTITION step requires the non-aligned unique index to be absent),
|
||||
// but step 5 rebuilds it before committing.
|
||||
var siteId = "purge-uxidx-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
var janEventId = Guid.NewGuid();
|
||||
var (janOccurred, _, _) = SeedOccurredAt();
|
||||
var oldEventId = Guid.NewGuid();
|
||||
var (oldOccurred, _, _, retentionDays) = SeedOccurredAt();
|
||||
|
||||
await using (var seedConn = _fixture.OpenConnection())
|
||||
{
|
||||
await DirectInsertAsync(seedConn, janEventId, janOccurred, siteId);
|
||||
await DirectInsertAsync(seedConn, oldEventId, oldOccurred, siteId);
|
||||
}
|
||||
|
||||
var services = new ServiceCollection();
|
||||
@@ -259,7 +295,7 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
IntervalHours = 24,
|
||||
IntervalOverride = TimeSpan.FromMilliseconds(100),
|
||||
},
|
||||
new AuditLogOptions { RetentionDays = 90 });
|
||||
new AuditLogOptions { RetentionDays = retentionDays });
|
||||
|
||||
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
probe.FishForMessage<AuditLogPurgedEvent>(
|
||||
@@ -281,18 +317,19 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Seed + purge a Jan-2026 row, THEN exercise InsertIfNotExistsAsync
|
||||
// twice for a fresh (May-2026) EventId. The second call must be a
|
||||
// no-op (duplicate-key collision swallowed by the repository, per
|
||||
// M2 Bundle A's race-fix) — which means the rebuilt
|
||||
// UX_AuditLog_EventId unique index is functioning as intended.
|
||||
// Seed + purge the Jan-2026 row, THEN exercise InsertIfNotExistsAsync twice for
|
||||
// a fresh recent EventId. The second call must be a no-op (duplicate-key collision
|
||||
// swallowed by the repository, per M2 Bundle A's race-fix) — which means the
|
||||
// rebuilt UX_AuditLog_EventId unique index is functioning as intended.
|
||||
// RetentionDays is computed dynamically so the threshold always lands near
|
||||
// 2026-01-20 (see SeedOccurredAt()).
|
||||
var siteId = "purge-idem-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
var janEventId = Guid.NewGuid();
|
||||
var (janOccurred, _, _) = SeedOccurredAt();
|
||||
var oldEventId = Guid.NewGuid();
|
||||
var (oldOccurred, _, _, retentionDays) = SeedOccurredAt();
|
||||
|
||||
await using (var seedConn = _fixture.OpenConnection())
|
||||
{
|
||||
await DirectInsertAsync(seedConn, janEventId, janOccurred, siteId);
|
||||
await DirectInsertAsync(seedConn, oldEventId, oldOccurred, siteId);
|
||||
}
|
||||
|
||||
var services = new ServiceCollection();
|
||||
@@ -312,7 +349,7 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
IntervalHours = 24,
|
||||
IntervalOverride = TimeSpan.FromMilliseconds(100),
|
||||
},
|
||||
new AuditLogOptions { RetentionDays = 90 });
|
||||
new AuditLogOptions { RetentionDays = retentionDays });
|
||||
|
||||
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
probe.FishForMessage<AuditLogPurgedEvent>(
|
||||
@@ -328,7 +365,7 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
var freshEventId = Guid.NewGuid();
|
||||
var freshOccurred = new DateTime(2026, 5, 15, 12, 0, 0, DateTimeKind.Utc);
|
||||
var freshOccurred = new DateTime(2026, 5, 15, 12, 0, 0, DateTimeKind.Utc); // within partition range, well inside retention window
|
||||
var freshSite = "purge-idem-fresh-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
var freshEvt = ScadaBridgeAuditEventFactory.Create(
|
||||
eventId: freshEventId,
|
||||
@@ -354,4 +391,87 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
Assert.Single(rows);
|
||||
Assert.Equal(freshEventId, rows[0].EventId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 4. PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows (M5.5 T3)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// M5.5 (T3): exercises <see cref="IAuditLogRepository.PurgeChannelOlderThanAsync"/>
|
||||
/// directly against the real repository + fixture DB. Seeds, in the SAME partition,
|
||||
/// old + recent rows for an OVERRIDDEN channel (<c>ApiOutbound</c>) and old + recent
|
||||
/// rows for an UN-overridden channel (<c>DbOutbound</c>), then runs the per-channel
|
||||
/// purge for <c>ApiOutbound</c> only. Asserts:
|
||||
/// <list type="number">
|
||||
/// <item>The overridden channel's OLD rows are deleted.</item>
|
||||
/// <item>The overridden channel's RECENT rows (newer than the channel threshold) survive.</item>
|
||||
/// <item>The un-overridden channel's rows (old AND recent) are completely untouched
|
||||
/// — they follow the global window, which the channel purge never applies to them.</item>
|
||||
/// </list>
|
||||
/// This is the maintenance-path row DELETE; the fixture connects as <c>sa</c>, which
|
||||
/// the append-only writer-role DENYs do not bind (the role granularity is exercised
|
||||
/// in the repository/migration tests).
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = "perchannel-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
|
||||
// Two timestamps: one OLD (older than the channel threshold we will purge with)
|
||||
// and one RECENT (newer than it). Both sit comfortably inside the retention
|
||||
// window so the global partition purge would NOT touch either — isolating the
|
||||
// per-channel DELETE as the only force acting here.
|
||||
var oldOccurred = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc);
|
||||
var recentOccurred = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var apiOldId = Guid.NewGuid(); // ApiOutbound, old → SHOULD be deleted
|
||||
var apiRecentId = Guid.NewGuid(); // ApiOutbound, recent→ SHOULD survive
|
||||
var dbOldId = Guid.NewGuid(); // DbOutbound, old → SHOULD survive (un-overridden)
|
||||
var dbRecentId = Guid.NewGuid(); // DbOutbound, recent → SHOULD survive
|
||||
|
||||
await using (var seedConn = _fixture.OpenConnection())
|
||||
{
|
||||
await DirectInsertAsync(seedConn, apiOldId, oldOccurred, siteId, channel: "ApiOutbound", kind: "ApiCall");
|
||||
await DirectInsertAsync(seedConn, apiRecentId, recentOccurred, siteId, channel: "ApiOutbound", kind: "ApiCall");
|
||||
await DirectInsertAsync(seedConn, dbOldId, oldOccurred, siteId, channel: "DbOutbound", kind: "DbWrite");
|
||||
await DirectInsertAsync(seedConn, dbRecentId, recentOccurred, siteId, channel: "DbOutbound", kind: "DbWrite");
|
||||
}
|
||||
|
||||
// Purge ApiOutbound rows older than a threshold that sits strictly between the
|
||||
// old (Jan 15) and recent (May 15) seeds — e.g. Mar 1. Only apiOldId qualifies.
|
||||
var channelThreshold = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
await using (var ctx = CreateContext())
|
||||
{
|
||||
var repo = new AuditLogRepository(ctx);
|
||||
var deleted = await repo.PurgeChannelOlderThanAsync(
|
||||
channel: "ApiOutbound",
|
||||
threshold: channelThreshold,
|
||||
batchSize: 2);
|
||||
|
||||
Assert.Equal(1L, deleted);
|
||||
|
||||
// Idempotent: a second run deletes nothing (the eligible row is gone).
|
||||
var deletedAgain = await repo.PurgeChannelOlderThanAsync(
|
||||
channel: "ApiOutbound",
|
||||
threshold: channelThreshold,
|
||||
batchSize: 2);
|
||||
Assert.Equal(0L, deletedAgain);
|
||||
}
|
||||
|
||||
await using var verify = CreateContext();
|
||||
var rows = await verify.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
// Overridden channel: old gone, recent kept.
|
||||
Assert.DoesNotContain(rows, r => r.EventId == apiOldId);
|
||||
Assert.Contains(rows, r => r.EventId == apiRecentId);
|
||||
|
||||
// Un-overridden channel: BOTH rows untouched (follow the global window).
|
||||
Assert.Contains(rows, r => r.EventId == dbOldId);
|
||||
Assert.Contains(rows, r => r.EventId == dbRecentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.CommandLine;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadabridge audit backfill-source-node</c> subcommand
|
||||
/// (Audit Log #23 M5.6 T5): argument parsing, request-body construction,
|
||||
/// HTTP wiring, and CLI scaffold.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
public class AuditBackfillCommandTests
|
||||
{
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// BuildRequestBody
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildRequestBody_DefaultArgs_ContainsExpectedFields()
|
||||
{
|
||||
var args = new AuditBackfillSourceNodeArgs
|
||||
{
|
||||
Sentinel = "unknown",
|
||||
Before = "2026-01-01T00:00:00Z",
|
||||
BatchSize = 5000,
|
||||
};
|
||||
|
||||
var body = AuditBackfillHelpers.BuildRequestBody(args);
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal("unknown", root.GetProperty("sentinel").GetString());
|
||||
Assert.Equal("2026-01-01T00:00:00Z", root.GetProperty("before").GetString());
|
||||
Assert.Equal(5000, root.GetProperty("batchSize").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRequestBody_CustomSentinelAndBatch_ReflectedInJson()
|
||||
{
|
||||
var args = new AuditBackfillSourceNodeArgs
|
||||
{
|
||||
Sentinel = "pre-feature",
|
||||
Before = "2026-06-01T00:00:00Z",
|
||||
BatchSize = 1000,
|
||||
};
|
||||
|
||||
var body = AuditBackfillHelpers.BuildRequestBody(args);
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal("pre-feature", root.GetProperty("sentinel").GetString());
|
||||
Assert.Equal("2026-06-01T00:00:00Z", root.GetProperty("before").GetString());
|
||||
Assert.Equal(1000, root.GetProperty("batchSize").GetInt32());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// RunBackfillAsync — HTTP execution
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class CapturingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _responseBody;
|
||||
|
||||
public CapturingHandler(HttpStatusCode status, string responseBody)
|
||||
{
|
||||
_status = status;
|
||||
_responseBody = responseBody;
|
||||
}
|
||||
|
||||
public string? LastRequestUri { get; private set; }
|
||||
public string? LastRequestBody { get; private set; }
|
||||
public string? LastMethod { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequestUri = request.RequestUri!.PathAndQuery;
|
||||
LastMethod = request.Method.Method;
|
||||
if (request.Content != null)
|
||||
{
|
||||
LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
return new HttpResponseMessage(_status)
|
||||
{
|
||||
Content = new StringContent(_responseBody, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string SuccessBody(long rowsUpdated = 42, string sentinel = "unknown", string before = "2026-01-01T00:00:00.0000000Z")
|
||||
=> JsonSerializer.Serialize(new { rowsUpdated, sentinel, before });
|
||||
|
||||
[Fact]
|
||||
public async Task RunBackfill_Success_ReturnsZeroAndWritesOutput()
|
||||
{
|
||||
var handler = new CapturingHandler(HttpStatusCode.OK, SuccessBody(rowsUpdated: 42));
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var args = new AuditBackfillSourceNodeArgs
|
||||
{
|
||||
Sentinel = "unknown",
|
||||
Before = "2026-01-01T00:00:00Z",
|
||||
BatchSize = 5000,
|
||||
};
|
||||
|
||||
var exit = await AuditBackfillHelpers.RunBackfillAsync(client, args, output);
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
var text = output.ToString();
|
||||
Assert.Contains("42", text);
|
||||
Assert.Contains("backfill complete", text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunBackfill_RequestUri_ContainsBackfillPath()
|
||||
{
|
||||
var handler = new CapturingHandler(HttpStatusCode.OK, SuccessBody());
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
await AuditBackfillHelpers.RunBackfillAsync(
|
||||
client,
|
||||
new AuditBackfillSourceNodeArgs { Sentinel = "unknown", Before = "2026-01-01T00:00:00Z" },
|
||||
output);
|
||||
|
||||
Assert.Contains("backfill-source-node", handler.LastRequestUri);
|
||||
Assert.Equal("POST", handler.LastMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunBackfill_RequestBody_ContainsSentinelAndBefore()
|
||||
{
|
||||
var handler = new CapturingHandler(HttpStatusCode.OK, SuccessBody());
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
await AuditBackfillHelpers.RunBackfillAsync(
|
||||
client,
|
||||
new AuditBackfillSourceNodeArgs
|
||||
{
|
||||
Sentinel = "pre-feature",
|
||||
Before = "2026-01-01T00:00:00Z",
|
||||
BatchSize = 2000,
|
||||
},
|
||||
output);
|
||||
|
||||
Assert.NotNull(handler.LastRequestBody);
|
||||
using var doc = JsonDocument.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("pre-feature", doc.RootElement.GetProperty("sentinel").GetString());
|
||||
Assert.Equal("2026-01-01T00:00:00Z", doc.RootElement.GetProperty("before").GetString());
|
||||
Assert.Equal(2000, doc.RootElement.GetProperty("batchSize").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunBackfill_Http403_ReturnsExitCode2()
|
||||
{
|
||||
var handler = new CapturingHandler(HttpStatusCode.Forbidden,
|
||||
"{\"error\":\"Permission required.\",\"code\":\"UNAUTHORIZED\"}");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditBackfillHelpers.RunBackfillAsync(
|
||||
client,
|
||||
new AuditBackfillSourceNodeArgs { Sentinel = "unknown", Before = "2026-01-01T00:00:00Z" },
|
||||
output);
|
||||
|
||||
Assert.Equal(2, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunBackfill_Http500_ReturnsExitCode1()
|
||||
{
|
||||
var handler = new CapturingHandler(HttpStatusCode.InternalServerError,
|
||||
"{\"error\":\"boom\",\"code\":\"INTERNAL\"}");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditBackfillHelpers.RunBackfillAsync(
|
||||
client,
|
||||
new AuditBackfillSourceNodeArgs { Sentinel = "unknown", Before = "2026-01-01T00:00:00Z" },
|
||||
output);
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// CLI parsing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BackfillSourceNode_Subcommand_ExistsInAuditCommandGroup()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[] { "audit", "backfill-source-node", "--help" });
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfillSourceNode_BeforeOption_IsRequired()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "backfill-source-node");
|
||||
Assert.NotEqual(0, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfillSourceNode_HelpText_DescribesSentinelAndBefore()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var output = new StringWriter();
|
||||
var exit = root.Parse(new[] { "audit", "backfill-source-node", "--help" })
|
||||
.Invoke(new InvocationConfiguration { Output = output });
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
var text = output.ToString();
|
||||
Assert.Contains("sentinel", text, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("before", text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfillSourceNode_DefaultSentinel_IsUnknown()
|
||||
{
|
||||
// Verify the default sentinel value is "unknown" as documented.
|
||||
var url = new Option<string>("--url") { Recursive = true };
|
||||
var username = new Option<string>("--username") { Recursive = true };
|
||||
var password = new Option<string>("--password") { Recursive = true };
|
||||
var format = CliOptions.CreateFormatOption();
|
||||
|
||||
var auditGroup = AuditCommands.Build(url, format, username, password);
|
||||
var backfillCmd = auditGroup.Subcommands
|
||||
.FirstOrDefault(c => c.Name == "backfill-source-node");
|
||||
|
||||
Assert.NotNull(backfillCmd);
|
||||
|
||||
// The subcommand exists and its description mentions maintenance/sentinel.
|
||||
Assert.False(string.IsNullOrWhiteSpace(backfillCmd!.Description));
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffold tests for the <c>scadabridge audit</c> command group (Audit Log #23 M8-T1).
|
||||
/// Verifies the parent command exists with its three subcommands and that every leaf
|
||||
/// has an action wired.
|
||||
/// Verifies the parent command exists with its subcommands and that every leaf
|
||||
/// has an action wired. Updated for M5.6 T5 to cover <c>backfill-source-node</c>.
|
||||
/// </summary>
|
||||
public class AuditCommandsScaffoldTests
|
||||
{
|
||||
@@ -27,11 +27,13 @@ public class AuditCommandsScaffoldTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audit_HasThreeSubcommands_QueryExportVerifyChain()
|
||||
public void Audit_HasFiveSubcommands_QueryExportTreeVerifyChainBackfillSourceNode()
|
||||
{
|
||||
var audit = BuildAudit();
|
||||
var names = audit.Subcommands.Select(c => c.Name).OrderBy(n => n).ToArray();
|
||||
Assert.Equal(new[] { "export", "query", "verify-chain" }, names);
|
||||
Assert.Equal(
|
||||
new[] { "backfill-source-node", "export", "query", "tree", "verify-chain" },
|
||||
names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -48,7 +50,9 @@ public class AuditCommandsScaffoldTests
|
||||
var text = output.ToString();
|
||||
Assert.Contains("query", text);
|
||||
Assert.Contains("export", text);
|
||||
Assert.Contains("tree", text);
|
||||
Assert.Contains("verify-chain", text);
|
||||
Assert.Contains("backfill-source-node", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
using System.CommandLine;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadabridge audit tree</c> subcommand (Audit Log #23 M5.1-T8):
|
||||
/// tree rendering (table format), JSON output, error handling, and CLI parsing.
|
||||
/// </summary>
|
||||
[Collection("Console")]
|
||||
public class AuditTreeCommandTests
|
||||
{
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// JSON parsing helpers
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string NodeJson(
|
||||
string executionId,
|
||||
string? parentId = null,
|
||||
int rowCount = 3,
|
||||
string[]? channels = null,
|
||||
string[]? statuses = null,
|
||||
string? siteId = "plant-a",
|
||||
string? instanceId = "inst-1",
|
||||
string? first = "2026-05-20T10:00:00Z",
|
||||
string? last = "2026-05-20T10:01:00Z")
|
||||
{
|
||||
var parentStr = parentId != null ? $"\"{parentId}\"" : "null";
|
||||
var channelArr = channels is { Length: > 0 }
|
||||
? "[" + string.Join(",", channels.Select(c => $"\"{c}\"")) + "]"
|
||||
: "[\"ApiOutbound\"]";
|
||||
var statusArr = statuses is { Length: > 0 }
|
||||
? "[" + string.Join(",", statuses.Select(s => $"\"{s}\"")) + "]"
|
||||
: "[\"Delivered\"]";
|
||||
var siteStr = siteId != null ? $"\"{siteId}\"" : "null";
|
||||
var instanceStr = instanceId != null ? $"\"{instanceId}\"" : "null";
|
||||
var firstStr = first != null ? $"\"{first}\"" : "null";
|
||||
var lastStr = last != null ? $"\"{last}\"" : "null";
|
||||
|
||||
return $@"{{
|
||||
""executionId"":""{executionId}"",
|
||||
""parentExecutionId"":{parentStr},
|
||||
""rowCount"":{rowCount},
|
||||
""channels"":{channelArr},
|
||||
""statuses"":{statusArr},
|
||||
""sourceSiteId"":{siteStr},
|
||||
""sourceInstanceId"":{instanceStr},
|
||||
""firstOccurredAtUtc"":{firstStr},
|
||||
""lastOccurredAtUtc"":{lastStr}
|
||||
}}";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// ParseNodes
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseNodes_ValidArray_ReturnsDtos()
|
||||
{
|
||||
var root = "11111111-1111-1111-1111-111111111111";
|
||||
var child = "22222222-2222-2222-2222-222222222222";
|
||||
var json = $"[{NodeJson(root)},{NodeJson(child, parentId: root)}]";
|
||||
|
||||
var nodes = AuditTreeHelpers.ParseNodes(json);
|
||||
|
||||
Assert.Equal(2, nodes.Length);
|
||||
Assert.Equal(Guid.Parse(root), nodes[0].ExecutionId);
|
||||
Assert.Null(nodes[0].ParentExecutionId);
|
||||
Assert.Equal(Guid.Parse(child), nodes[1].ExecutionId);
|
||||
Assert.Equal(Guid.Parse(root), nodes[1].ParentExecutionId);
|
||||
Assert.Equal(3, nodes[0].RowCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseNodes_EmptyArray_ReturnsEmpty()
|
||||
{
|
||||
var nodes = AuditTreeHelpers.ParseNodes("[]");
|
||||
Assert.Empty(nodes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseNodes_InvalidJson_ReturnsEmpty()
|
||||
{
|
||||
var nodes = AuditTreeHelpers.ParseNodes("not-json");
|
||||
Assert.Empty(nodes);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// WriteTable — ASCII tree rendering
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void WriteTable_EmptyNodes_PrintsFallbackMessage()
|
||||
{
|
||||
var output = new StringWriter();
|
||||
AuditTreeHelpers.WriteTable(Array.Empty<AuditTreeNodeDto>(), Guid.NewGuid(), output);
|
||||
Assert.Contains("no execution tree found", output.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteTable_SingleRootNode_PrintsWithNoIndent()
|
||||
{
|
||||
var rootId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var nodes = AuditTreeHelpers.ParseNodes($"[{NodeJson(rootId.ToString())}]");
|
||||
|
||||
var output = new StringWriter();
|
||||
AuditTreeHelpers.WriteTable(nodes, rootId, output);
|
||||
var text = output.ToString();
|
||||
|
||||
// Root node printed at column 0 (no leading spaces).
|
||||
var line = text.Split('\n', StringSplitOptions.RemoveEmptyEntries).First();
|
||||
Assert.StartsWith(rootId.ToString("D"), line);
|
||||
Assert.Contains("[*]", line); // queried node marked
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteTable_MultiLevelTree_IndentsChildrenCorrectly()
|
||||
{
|
||||
var rootId = "11111111-1111-1111-1111-111111111111";
|
||||
var childId = "22222222-2222-2222-2222-222222222222";
|
||||
var grandChildId = "33333333-3333-3333-3333-333333333333";
|
||||
var json = $"[{NodeJson(rootId)},{NodeJson(childId, parentId: rootId)},{NodeJson(grandChildId, parentId: childId)}]";
|
||||
var nodes = AuditTreeHelpers.ParseNodes(json);
|
||||
|
||||
var output = new StringWriter();
|
||||
AuditTreeHelpers.WriteTable(nodes, Guid.Parse(rootId), output);
|
||||
var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Root: no indent.
|
||||
Assert.True(lines[0].StartsWith(rootId, StringComparison.OrdinalIgnoreCase) ||
|
||||
lines[0].StartsWith(rootId.ToUpper(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Child: 2-space indent (exactly 2, not 4+).
|
||||
var childLine = lines.First(l => l.Contains(childId));
|
||||
Assert.StartsWith(" ", childLine);
|
||||
Assert.False(childLine.StartsWith(" ", StringComparison.Ordinal), "child should be indented exactly 2, not 4+");
|
||||
|
||||
// Grandchild: 4-space indent.
|
||||
var grandLine = lines.First(l => l.Contains(grandChildId));
|
||||
Assert.StartsWith(" ", grandLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteTable_QueriedNodeIsMarked_OthersAreNot()
|
||||
{
|
||||
var rootId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var childId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
var json = $"[{NodeJson(rootId.ToString())},{NodeJson(childId.ToString(), parentId: rootId.ToString())}]";
|
||||
var nodes = AuditTreeHelpers.ParseNodes(json);
|
||||
|
||||
// Query via child ID — child should be marked, root should not.
|
||||
var output = new StringWriter();
|
||||
AuditTreeHelpers.WriteTable(nodes, childId, output);
|
||||
var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
var childLine = lines.First(l => l.Contains(childId.ToString("D")));
|
||||
var rootLine = lines.First(l => l.Contains(rootId.ToString("D")));
|
||||
Assert.Contains("[*]", childLine);
|
||||
Assert.DoesNotContain("[*]", rootLine);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// WriteJson
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void WriteJson_ValidNodes_EmitsValidJsonArray()
|
||||
{
|
||||
var rootId = "11111111-1111-1111-1111-111111111111";
|
||||
var childId = "22222222-2222-2222-2222-222222222222";
|
||||
var nodes = AuditTreeHelpers.ParseNodes($"[{NodeJson(rootId)},{NodeJson(childId, parentId: rootId)}]");
|
||||
|
||||
var output = new StringWriter();
|
||||
AuditTreeHelpers.WriteJson(nodes, output);
|
||||
var text = output.ToString();
|
||||
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(2, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteJson_EmptyNodes_EmitsEmptyArray()
|
||||
{
|
||||
var output = new StringWriter();
|
||||
AuditTreeHelpers.WriteJson(Array.Empty<AuditTreeNodeDto>(), output);
|
||||
var text = output.ToString().Trim();
|
||||
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// RunTreeAsync — HTTP execution
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class FixedHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _body;
|
||||
|
||||
public FixedHandler(HttpStatusCode status, string body)
|
||||
{
|
||||
_status = status;
|
||||
_body = body;
|
||||
}
|
||||
|
||||
public string? LastRequestUri { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequestUri = request.RequestUri!.PathAndQuery;
|
||||
return Task.FromResult(new HttpResponseMessage(_status)
|
||||
{
|
||||
Content = new StringContent(_body, Encoding.UTF8, "application/json"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunTree_Success_ReturnsZeroAndWritesOutput()
|
||||
{
|
||||
var rootId = "11111111-1111-1111-1111-111111111111";
|
||||
var json = $"[{NodeJson(rootId)}]";
|
||||
var handler = new FixedHandler(HttpStatusCode.OK, json);
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditTreeHelpers.RunTreeAsync(
|
||||
client, Guid.Parse(rootId), "table", output);
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
Assert.Contains(rootId, output.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunTree_EmptyResponse_ReturnsZeroWithFallbackMessage()
|
||||
{
|
||||
var handler = new FixedHandler(HttpStatusCode.OK, "[]");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditTreeHelpers.RunTreeAsync(
|
||||
client, Guid.NewGuid(), "table", output);
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
Assert.Contains("no execution tree found", output.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunTree_JsonFormat_EmitsValidJson()
|
||||
{
|
||||
var rootId = "11111111-1111-1111-1111-111111111111";
|
||||
var handler = new FixedHandler(HttpStatusCode.OK, $"[{NodeJson(rootId)}]");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditTreeHelpers.RunTreeAsync(
|
||||
client, Guid.Parse(rootId), "json", output);
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
using var doc = JsonDocument.Parse(output.ToString());
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunTree_Http403_ReturnsExitCode2()
|
||||
{
|
||||
var handler = new FixedHandler(HttpStatusCode.Forbidden, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditTreeHelpers.RunTreeAsync(
|
||||
client, Guid.NewGuid(), "table", output);
|
||||
|
||||
Assert.Equal(2, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunTree_Http500_ReturnsExitCode1()
|
||||
{
|
||||
var handler = new FixedHandler(HttpStatusCode.InternalServerError, "{\"error\":\"boom\",\"code\":\"INTERNAL\"}");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
var exit = await AuditTreeHelpers.RunTreeAsync(
|
||||
client, Guid.NewGuid(), "table", output);
|
||||
|
||||
Assert.Equal(1, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunTree_RequestUrlContainsExecutionId()
|
||||
{
|
||||
var id = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var handler = new FixedHandler(HttpStatusCode.OK, "[]");
|
||||
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
|
||||
var output = new StringWriter();
|
||||
|
||||
await AuditTreeHelpers.RunTreeAsync(client, id, "table", output);
|
||||
|
||||
Assert.Contains("11111111-1111-1111-1111-111111111111", handler.LastRequestUri);
|
||||
Assert.Contains("executionId", handler.LastRequestUri);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// CLI parsing — audit tree subcommand
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Tree_Subcommand_ExistsInAuditCommandGroup()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var parse = root.Parse(new[] { "audit", "tree", "--help" });
|
||||
// --help is never an error, exit 0.
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tree_ExecutionIdOption_IsRequired()
|
||||
{
|
||||
// Invoking without --execution-id must produce an error (the option is Required).
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "tree");
|
||||
// System.CommandLine returns non-zero for a missing required option.
|
||||
Assert.NotEqual(0, exit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tree_HelpText_DescribesExecutionId()
|
||||
{
|
||||
var root = AuditCommandTestHarness.BuildRoot();
|
||||
var output = new StringWriter();
|
||||
var exit = root.Parse(new[] { "audit", "tree", "--help" })
|
||||
.Invoke(new InvocationConfiguration { Output = output });
|
||||
|
||||
Assert.Equal(0, exit);
|
||||
Assert.Contains("execution-id", output.ToString());
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
using HealthPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.Health;
|
||||
@@ -232,13 +233,18 @@ public class HealthPageTests : BunitContext
|
||||
|
||||
/// <summary>
|
||||
/// Stand-in for the Site Call Audit actor. Replies to the KPI request with
|
||||
/// the test's currently-scripted response.
|
||||
/// the test's currently-scripted response. Also handles the per-node KPI
|
||||
/// request (T6: M5.2) with an empty-nodes success reply so the Health page
|
||||
/// can complete initialization without a 30-second Ask timeout.
|
||||
/// </summary>
|
||||
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
|
||||
{
|
||||
public ScriptedSiteCallAuditActor(HealthPageTests test)
|
||||
{
|
||||
Receive<SiteCallKpiRequest>(_ => Sender.Tell(test._siteCallKpiReply));
|
||||
Receive<PerNodeSiteCallKpiRequest>(req => Sender.Tell(
|
||||
new PerNodeSiteCallKpiResponse(req.CorrelationId, Success: true, ErrorMessage: null,
|
||||
Nodes: Array.Empty<SiteCallNodeKpiSnapshot>())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,9 @@ public class NotificationKpisPageTests : BunitContext
|
||||
|
||||
/// <summary>
|
||||
/// Stand-in for the notification-outbox actor. Replies to each KPI message
|
||||
/// type with the test's currently-scripted response.
|
||||
/// type with the test's currently-scripted response. Also handles the per-node
|
||||
/// KPI request (T6: M5.2) with an empty-nodes success reply so the page can
|
||||
/// complete initialization without a 30-second Ask timeout.
|
||||
/// </summary>
|
||||
private sealed class ScriptedOutboxActor : ReceiveActor
|
||||
{
|
||||
@@ -161,6 +163,9 @@ public class NotificationKpisPageTests : BunitContext
|
||||
{
|
||||
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
|
||||
Receive<PerSiteNotificationKpiRequest>(_ => Sender.Tell(test._perSiteReply));
|
||||
Receive<PerNodeNotificationKpiRequest>(req => Sender.Tell(
|
||||
new PerNodeNotificationKpiResponse(req.CorrelationId, Success: true, ErrorMessage: null,
|
||||
Nodes: Array.Empty<NodeNotificationKpiSnapshot>())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+154
-4
@@ -31,9 +31,40 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
/// targeting the AuditLog entity are NOT covered and must never be introduced.
|
||||
/// Additionally, the scan is line-oriented: DML where the keyword and table name appear
|
||||
/// on separate lines is an accepted, undetected edge case.
|
||||
///
|
||||
/// <b>Allow-list.</b> Two narrow maintenance-path exemptions carry the exact
|
||||
/// <see cref="AuditPurgeAllowedMarker"/> trailing comment:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// M5.5 (T3) — <c>AuditLogRepository.PurgeChannelOlderThanAsync</c>: the
|
||||
/// one sanctioned batched <c>DELETE TOP (@batch) FROM dbo.AuditLog</c>,
|
||||
/// running on the purge/maintenance connection.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// M5.6 (T5) — <c>AuditLogRepository.BackfillSourceNodeAsync</c>: the
|
||||
/// one sanctioned batched <c>UPDATE TOP (@batch) dbo.AuditLog SET SourceNode</c>,
|
||||
/// running on the maintenance connection. The sentinel backfill is a
|
||||
/// one-time ops procedure; the append-only invariant still applies to all
|
||||
/// other columns and all other UPDATE forms.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// The allow-list is applied in the file-scan test only
|
||||
/// (<see cref="ConfigurationDatabase_ShouldNotContainAuditLogMutations"/>) — the
|
||||
/// raw mutation matcher (<see cref="ContainsAuditLogMutation"/>) is marker-blind,
|
||||
/// so the matcher's self-tests remain honest and any OTHER UPDATE/DELETE against
|
||||
/// AuditLog (or any DML lacking the marker) still fails the build.
|
||||
/// </summary>
|
||||
public class AuditLogAppendOnlyGuardTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The exact trailing-comment marker that exempts a single sanctioned
|
||||
/// maintenance-path DML line from the append-only guard. Carried at the END of
|
||||
/// the SQL constant string in both <c>AuditLogRepository.PurgeChannelOlderThanAsync</c>
|
||||
/// (M5.5 T3 batched DELETE) and <c>AuditLogRepository.BackfillSourceNodeAsync</c>
|
||||
/// (M5.6 T5 batched UPDATE). Kept deliberately specific so it cannot be pasted
|
||||
/// onto an unrelated mutation without a reviewer noticing.
|
||||
/// </summary>
|
||||
internal const string AuditPurgeAllowedMarker = "AUDIT-PURGE-ALLOWED";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source root location — same walk-up pattern used by ArchitecturalConstraintTests
|
||||
// in the Commons.Tests project.
|
||||
@@ -133,11 +164,38 @@ public class AuditLogAppendOnlyGuardTests
|
||||
return AuditLogMutationPattern.IsMatch(text);
|
||||
}
|
||||
|
||||
// The DELETE branch tolerates an optional TOP (...) batch-size clause between
|
||||
// DELETE and the (optional) FROM — e.g. "DELETE TOP (@batch) FROM dbo.AuditLog"
|
||||
// (the M5.5 T3 batched purge shape). Without this the guard would silently miss a
|
||||
// batched row DELETE against AuditLog, which is exactly the kind of mutation it
|
||||
// must catch. The TOP sub-pattern is (?:TOP\s*\(.*?\)\s+)? — optional, lazy inside
|
||||
// the parens so it never swallows past the matching ')'.
|
||||
//
|
||||
// The UPDATE branch similarly tolerates an optional TOP (...) clause between
|
||||
// UPDATE and (optional schema.) AuditLog — e.g.
|
||||
// "UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel …"
|
||||
// (the M5.6 T5 batched backfill shape).
|
||||
private static readonly Regex AuditLogMutationPattern = new(
|
||||
@"\bUPDATE\s+(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b" +
|
||||
@"|\bDELETE\s+(?:FROM\s+)?(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b",
|
||||
@"\bUPDATE\s+(?:TOP\s*\(.*?\)\s+)?(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b" +
|
||||
@"|\bDELETE\s+(?:TOP\s*\(.*?\)\s+)?(?:FROM\s+)?(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when <paramref name="line"/> carries the narrow
|
||||
/// <see cref="AuditPurgeAllowedMarker"/> exemption. Sanctioned uses are:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>M5.5 T3 — the per-channel maintenance-path batched DELETE.</description></item>
|
||||
/// <item><description>M5.6 T5 — the SourceNode sentinel batched UPDATE.</description></item>
|
||||
/// </list>
|
||||
/// A flagged line that lacks the marker is NOT allow-listed. The mutation matcher
|
||||
/// itself stays marker-blind; the allow-list is applied only by the file-scan test,
|
||||
/// so the matcher's self-tests still observe the raw mutation.
|
||||
/// </summary>
|
||||
/// <param name="line">A single source line already known to contain a mutation.</param>
|
||||
/// <returns><see langword="true"/> if the line is a sanctioned maintenance-path exemption.</returns>
|
||||
internal static bool IsAllowListed(string line) =>
|
||||
line.Contains(AuditPurgeAllowedMarker, StringComparison.Ordinal);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guard test: scan every *.cs file in ConfigurationDatabase (excluding
|
||||
// Designer/Snapshot EF artefacts and the obj/ directory).
|
||||
@@ -168,7 +226,7 @@ public class AuditLogAppendOnlyGuardTests
|
||||
var lines = content.Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (ContainsAuditLogMutation(lines[i]))
|
||||
if (ContainsAuditLogMutation(lines[i]) && !IsAllowListed(lines[i]))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(sourceDir, file);
|
||||
violations.Add($"{relativePath}:{i + 1}: {lines[i].Trim()}");
|
||||
@@ -179,7 +237,7 @@ public class AuditLogAppendOnlyGuardTests
|
||||
Assert.True(violations.Count == 0,
|
||||
"AuditLog append-only guard: found UPDATE/DELETE targeting dbo.AuditLog " +
|
||||
"in ConfigurationDatabase source. AuditLog is APPEND-ONLY (retention uses " +
|
||||
"partition-switch DDL, not row DELETE). Violation(s):\n" +
|
||||
"partition-switch DDL, not row DELETE/UPDATE). Violation(s):\n" +
|
||||
string.Join("\n", violations));
|
||||
}
|
||||
|
||||
@@ -285,6 +343,27 @@ public class AuditLogAppendOnlyGuardTests
|
||||
// DELETE FROM [AuditLog] — bracketed table, no schema prefix.
|
||||
Assert.True(ContainsAuditLogMutation(
|
||||
"DELETE FROM [AuditLog] WHERE OccurredAtUtc < @threshold;"));
|
||||
|
||||
// ---- Batched DELETE TOP (...) forms (M5.5 T3 purge shape) ----
|
||||
// The matcher must catch a batched DELETE against AuditLog regardless of the
|
||||
// marker — the allow-list (IsAllowListed) is what forgives the ONE sanctioned
|
||||
// line, not the matcher.
|
||||
Assert.True(ContainsAuditLogMutation(
|
||||
"DELETE TOP (@batch) FROM dbo.AuditLog WHERE Category = @channel AND OccurredAtUtc < @threshold;"));
|
||||
Assert.True(ContainsAuditLogMutation(
|
||||
"DELETE TOP (5000) FROM dbo.AuditLog WHERE OccurredAtUtc < @threshold;"));
|
||||
Assert.True(ContainsAuditLogMutation(
|
||||
"DELETE TOP(100) FROM [dbo].[AuditLog] WHERE Status = 'Parked';"));
|
||||
|
||||
// ---- Batched UPDATE TOP (...) forms (M5.6 T5 backfill shape) ----
|
||||
// The matcher must also catch a batched UPDATE against AuditLog, regardless of
|
||||
// the marker — the allow-list is what forgives the ONE sanctioned backfill line.
|
||||
Assert.True(ContainsAuditLogMutation(
|
||||
"UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel WHERE SourceNode IS NULL AND OccurredAtUtc < @before;"));
|
||||
Assert.True(ContainsAuditLogMutation(
|
||||
"UPDATE TOP (500) dbo.AuditLog SET SourceNode = 'unknown' WHERE SourceNode IS NULL;"));
|
||||
Assert.True(ContainsAuditLogMutation(
|
||||
"UPDATE TOP(100) [dbo].[AuditLog] SET SourceNode = @s WHERE SourceNode IS NULL;"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -315,4 +394,75 @@ public class AuditLogAppendOnlyGuardTests
|
||||
Assert.False(ContainsAuditLogMutation(
|
||||
"DELETE FROM dbo.SiteCalls WHERE TerminalAtUtc < @cutoff;"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Allow-list self-tests (M5.5 T3 / M5.6 T5) — prove the narrow exemption only
|
||||
// forgives the marked maintenance-path DML and still blocks everything else.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AllowList_ForgivesMarkedPurgeDelete_ButMatcherStillTrips()
|
||||
{
|
||||
// The sanctioned per-channel purge DELETE — verbatim shape from
|
||||
// AuditLogRepository.PurgeChannelOlderThanAsync, carrying the trailing marker.
|
||||
const string sanctioned =
|
||||
"\"DELETE TOP (@batch) FROM dbo.AuditLog WHERE Category = @channel AND OccurredAtUtc < @threshold;\"; " +
|
||||
"// AUDIT-PURGE-ALLOWED: per-channel retention override (M5.5 T3), maintenance path";
|
||||
|
||||
// The raw matcher STILL sees the mutation (the matcher is marker-blind) ...
|
||||
Assert.True(ContainsAuditLogMutation(sanctioned));
|
||||
// ... but the allow-list forgives it because of the trailing marker.
|
||||
Assert.True(IsAllowListed(sanctioned));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowList_ForgivesMarkedBackfillUpdate_ButMatcherStillTrips()
|
||||
{
|
||||
// The sanctioned SourceNode sentinel backfill UPDATE — verbatim shape from
|
||||
// AuditLogRepository.BackfillSourceNodeAsync, carrying the trailing marker.
|
||||
const string sanctioned =
|
||||
"\"UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel WHERE SourceNode IS NULL AND OccurredAtUtc < @before;\"; " +
|
||||
"// AUDIT-PURGE-ALLOWED: SourceNode sentinel backfill (M5.6 T5), maintenance path";
|
||||
|
||||
// The raw matcher STILL sees the mutation (the matcher is marker-blind) ...
|
||||
Assert.True(ContainsAuditLogMutation(sanctioned));
|
||||
// ... but the allow-list forgives it because of the trailing marker.
|
||||
Assert.True(IsAllowListed(sanctioned));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowList_DoesNotForgive_UnmarkedStrayDelete()
|
||||
{
|
||||
// A stray DELETE against AuditLog WITHOUT the marker — exactly the kind of
|
||||
// regression the guard exists to catch. It must be flagged (matcher) AND not
|
||||
// forgiven (allow-list), so the file-scan test would record it as a violation.
|
||||
const string stray = "DELETE FROM dbo.AuditLog WHERE Status = 'Parked';";
|
||||
|
||||
Assert.True(ContainsAuditLogMutation(stray));
|
||||
Assert.False(IsAllowListed(stray),
|
||||
"A DELETE against AuditLog without the AUDIT-PURGE-ALLOWED marker must NOT be allow-listed.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowList_DoesNotForgive_UnmarkedStrayUpdate()
|
||||
{
|
||||
// A stray UPDATE against AuditLog WITHOUT the marker — must still trip the guard.
|
||||
const string stray = "UPDATE dbo.AuditLog SET Status = 'Corrected' WHERE EventId = @id;";
|
||||
|
||||
Assert.True(ContainsAuditLogMutation(stray));
|
||||
Assert.False(IsAllowListed(stray),
|
||||
"An UPDATE against AuditLog without the AUDIT-PURGE-ALLOWED marker must NOT be allow-listed.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowList_DoesNotForgive_BatchedUpdateWithoutMarker()
|
||||
{
|
||||
// A batched UPDATE TOP ... AuditLog without the marker — the TOP clause variant
|
||||
// must also be caught and not forgiven without the explicit marker.
|
||||
const string stray = "UPDATE TOP (500) dbo.AuditLog SET SourceNode = 'unknown' WHERE SourceNode IS NULL;";
|
||||
|
||||
Assert.True(ContainsAuditLogMutation(stray));
|
||||
Assert.False(IsAllowListed(stray),
|
||||
"A batched UPDATE against AuditLog without the AUDIT-PURGE-ALLOWED marker must NOT be allow-listed.");
|
||||
}
|
||||
}
|
||||
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="AuditLogRepository.BackfillSourceNodeAsync"/>
|
||||
/// (M5.6 T5 — SourceNode sentinel backfill).
|
||||
///
|
||||
/// <para>
|
||||
/// These tests exercise the real <see cref="AuditLogRepository"/> against a
|
||||
/// per-class <see cref="MsSqlMigrationFixture"/> database, mirroring the
|
||||
/// style of <c>PartitionPurgeTests</c>. All tests are guarded with
|
||||
/// <c>[SkippableFact]</c> and skipped when the MSSQL container is absent.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class BackfillSourceNodeTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public BackfillSourceNodeTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private ScadaBridgeDbContext CreateContext() =>
|
||||
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer(_fixture.ConnectionString).Options);
|
||||
|
||||
private AuditLogRepository CreateRepo(ScadaBridgeDbContext ctx) => new(ctx);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Seed helper: direct INSERT bypassing the writer role, same pattern
|
||||
// as PartitionPurgeTests.DirectInsertAsync.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private async Task SeedRowAsync(
|
||||
SqlConnection conn,
|
||||
Guid eventId,
|
||||
DateTime occurredAtUtc,
|
||||
string? sourceNode)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
// Supply SourceNode explicitly (NULL or a value) so the test controls
|
||||
// which rows are eligible for backfill.
|
||||
cmd.CommandText = @"
|
||||
INSERT INTO dbo.AuditLog
|
||||
(EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson)
|
||||
VALUES
|
||||
(@EventId, @OccurredAtUtc, NULL, 'ApiOutbound.ApiCall', 'Success', 'ApiOutbound', NULL, @SourceNode, NULL,
|
||||
@DetailsJson);";
|
||||
|
||||
cmd.Parameters.Add("@EventId", System.Data.SqlDbType.UniqueIdentifier).Value = eventId;
|
||||
|
||||
var occurredParam = cmd.Parameters.Add("@OccurredAtUtc", System.Data.SqlDbType.DateTime2);
|
||||
occurredParam.Scale = 7;
|
||||
occurredParam.Value = occurredAtUtc;
|
||||
|
||||
var sourceNodeParam = cmd.Parameters.Add("@SourceNode", System.Data.SqlDbType.VarChar, 64);
|
||||
sourceNodeParam.Value = (object?)sourceNode ?? DBNull.Value;
|
||||
|
||||
var detailsJson =
|
||||
"{\"channel\":\"ApiOutbound\",\"kind\":\"ApiCall\",\"status\":\"Delivered\"," +
|
||||
"\"payloadTruncated\":false}";
|
||||
cmd.Parameters.Add("@DetailsJson", System.Data.SqlDbType.NVarChar, -1).Value = detailsJson;
|
||||
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private async Task<string?> ReadSourceNodeAsync(SqlConnection conn, Guid eventId)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT SourceNode FROM dbo.AuditLog WHERE EventId = @EventId;";
|
||||
cmd.Parameters.Add("@EventId", System.Data.SqlDbType.UniqueIdentifier).Value = eventId;
|
||||
var raw = await cmd.ExecuteScalarAsync();
|
||||
return raw == DBNull.Value ? null : (string?)raw;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 1. SetsNullRowsBeforeThreshold
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[SkippableFact]
|
||||
public async Task BackfillSourceNode_SetsNullRowsBeforeThreshold()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var before = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var eligibleId = Guid.NewGuid(); // NULL, occurred before threshold
|
||||
var tooNewId = Guid.NewGuid(); // NULL, occurred after threshold
|
||||
|
||||
await using var seedConn = _fixture.OpenConnection();
|
||||
await SeedRowAsync(seedConn, eligibleId,
|
||||
new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), sourceNode: null);
|
||||
await SeedRowAsync(seedConn, tooNewId,
|
||||
new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), sourceNode: null);
|
||||
|
||||
await using var ctx = CreateContext();
|
||||
var repo = CreateRepo(ctx);
|
||||
|
||||
var rows = await repo.BackfillSourceNodeAsync("unknown", before, batchSize: 1000);
|
||||
|
||||
Assert.True(rows >= 1, $"Expected at least 1 row updated; got {rows}.");
|
||||
|
||||
// eligible row: must now have the sentinel
|
||||
var eligibleNode = await ReadSourceNodeAsync(seedConn, eligibleId);
|
||||
Assert.Equal("unknown", eligibleNode);
|
||||
|
||||
// too-new row: must still be NULL
|
||||
var tooNewNode = await ReadSourceNodeAsync(seedConn, tooNewId);
|
||||
Assert.Null(tooNewNode);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 2. LeavesNonNullRowsUntouched
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[SkippableFact]
|
||||
public async Task BackfillSourceNode_LeavesNonNullRowsUntouched()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var before = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var alreadySetId = Guid.NewGuid(); // already has a SourceNode value
|
||||
|
||||
await using var seedConn = _fixture.OpenConnection();
|
||||
await SeedRowAsync(seedConn, alreadySetId,
|
||||
new DateTime(2026, 1, 10, 0, 0, 0, DateTimeKind.Utc), sourceNode: "node-a");
|
||||
|
||||
await using var ctx = CreateContext();
|
||||
var repo = CreateRepo(ctx);
|
||||
|
||||
await repo.BackfillSourceNodeAsync("unknown", before, batchSize: 1000);
|
||||
|
||||
// "node-a" must still be "node-a", not overwritten
|
||||
var node = await ReadSourceNodeAsync(seedConn, alreadySetId);
|
||||
Assert.Equal("node-a", node);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3. Idempotent_SecondRunUpdatesZeroRows
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[SkippableFact]
|
||||
public async Task BackfillSourceNode_Idempotent_SecondRunUpdatesZeroRows()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var before = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var idempotentId = Guid.NewGuid();
|
||||
|
||||
await using var seedConn = _fixture.OpenConnection();
|
||||
await SeedRowAsync(seedConn, idempotentId,
|
||||
new DateTime(2026, 1, 20, 0, 0, 0, DateTimeKind.Utc), sourceNode: null);
|
||||
|
||||
await using var ctx1 = CreateContext();
|
||||
var repo1 = CreateRepo(ctx1);
|
||||
var firstRun = await repo1.BackfillSourceNodeAsync("unknown", before, batchSize: 1000);
|
||||
Assert.True(firstRun >= 1, "First run should update at least 1 row.");
|
||||
|
||||
// Second run: no NULL rows remain for this threshold — must update 0.
|
||||
await using var ctx2 = CreateContext();
|
||||
var repo2 = CreateRepo(ctx2);
|
||||
var secondRun = await repo2.BackfillSourceNodeAsync("unknown", before, batchSize: 1000);
|
||||
// The second run must not update the already-sentinel row again.
|
||||
// We cannot assert exactly 0 because other tests share the same fixture DB
|
||||
// and may have left unrelated NULL rows; but the idempotentId row must not
|
||||
// have been touched (it already has "unknown", so the WHERE SourceNode IS NULL
|
||||
// filter excludes it).
|
||||
var node = await ReadSourceNodeAsync(seedConn, idempotentId);
|
||||
Assert.Equal("unknown", node);
|
||||
// The second run returning 0 would be true if no other NULL rows exist —
|
||||
// we assert the contract from the repo's perspective by checking the row.
|
||||
_ = secondRun; // acknowledged: value consumed
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 4. CustomSentinelIsWritten
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[SkippableFact]
|
||||
public async Task BackfillSourceNode_CustomSentinel_IsWritten()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var before = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var customId = Guid.NewGuid();
|
||||
|
||||
await using var seedConn = _fixture.OpenConnection();
|
||||
await SeedRowAsync(seedConn, customId,
|
||||
new DateTime(2026, 2, 5, 0, 0, 0, DateTimeKind.Utc), sourceNode: null);
|
||||
|
||||
await using var ctx = CreateContext();
|
||||
var repo = CreateRepo(ctx);
|
||||
|
||||
await repo.BackfillSourceNodeAsync("pre-feature", before, batchSize: 1000);
|
||||
|
||||
var node = await ReadSourceNodeAsync(seedConn, customId);
|
||||
Assert.Equal("pre-feature", node);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 5. ArgumentValidation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_EmptySentinel_Throws()
|
||||
{
|
||||
// Guard fires even without a DB connection — no Skip needed.
|
||||
// Use a null/empty context via a degenerate connection string; the
|
||||
// argument check fires before any SQL runs.
|
||||
await using var ctx = new ScadaBridgeDbContext(
|
||||
new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer("Server=.;Database=dummy;Connect Timeout=0;")
|
||||
.Options);
|
||||
var repo = new AuditLogRepository(ctx);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => repo.BackfillSourceNodeAsync("", DateTime.UtcNow, 1000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_ZeroBatchSize_Throws()
|
||||
{
|
||||
await using var ctx = new ScadaBridgeDbContext(
|
||||
new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer("Server=.;Database=dummy;Connect Timeout=0;")
|
||||
.Options);
|
||||
var repo = new AuditLogRepository(ctx);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
|
||||
() => repo.BackfillSourceNodeAsync("unknown", DateTime.UtcNow, 0));
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
// Coverage for per-node KPI aggregation in the Notification Outbox repository
|
||||
// (T6: M5.2 per-node stuck-count KPIs).
|
||||
public class NotificationOutboxRepositoryPerNodeKpiTests
|
||||
{
|
||||
private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext();
|
||||
|
||||
private static Notification NewNotification(
|
||||
string sourceSiteId,
|
||||
NotificationStatus status,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset? deliveredAt = null,
|
||||
string? sourceNode = null)
|
||||
{
|
||||
return new Notification(
|
||||
Guid.NewGuid().ToString(), NotificationType.Email, "Ops List", "Subject", "Body", sourceSiteId)
|
||||
{
|
||||
Status = status,
|
||||
CreatedAt = createdAt,
|
||||
DeliveredAt = deliveredAt,
|
||||
SourceNode = sourceNode,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputePerNodeKpisAsync_AggregatesMetricsPerNode()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// node-a: 1 pending (stuck, created 20m ago), 1 parked
|
||||
ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Pending,
|
||||
createdAt: now.AddMinutes(-20), sourceNode: "node-a"));
|
||||
ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Parked,
|
||||
createdAt: now.AddMinutes(-5), sourceNode: "node-a"));
|
||||
// node-b: 1 delivered in-window, 1 pending (fresh)
|
||||
ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Delivered,
|
||||
createdAt: now.AddHours(-2), deliveredAt: now.AddMinutes(-2), sourceNode: "node-b"));
|
||||
ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Pending,
|
||||
createdAt: now.AddMinutes(-1), sourceNode: "node-b"));
|
||||
// NULL SourceNode — must be excluded from per-node results
|
||||
ctx.Notifications.Add(NewNotification("plant-c", NotificationStatus.Pending,
|
||||
createdAt: now.AddMinutes(-5), sourceNode: null));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var repo = new NotificationOutboxRepository(ctx);
|
||||
var result = await repo.ComputePerNodeKpisAsync(
|
||||
stuckCutoff: now.AddMinutes(-10), deliveredSince: now.AddMinutes(-30));
|
||||
|
||||
// Only node-a and node-b — the null-node row is excluded.
|
||||
Assert.Equal(2, result.Count);
|
||||
|
||||
var a = result.Single(n => n.SourceNode == "node-a");
|
||||
Assert.Equal(1, a.QueueDepth);
|
||||
Assert.Equal(1, a.StuckCount);
|
||||
Assert.Equal(1, a.ParkedCount);
|
||||
Assert.Equal(0, a.DeliveredLastInterval);
|
||||
Assert.NotNull(a.OldestPendingAge);
|
||||
|
||||
var b = result.Single(n => n.SourceNode == "node-b");
|
||||
Assert.Equal(1, b.QueueDepth);
|
||||
Assert.Equal(0, b.StuckCount);
|
||||
Assert.Equal(0, b.ParkedCount);
|
||||
Assert.Equal(1, b.DeliveredLastInterval);
|
||||
Assert.NotNull(b.OldestPendingAge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputePerNodeKpisAsync_ExcludesNullSourceNode()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Only null-node rows — result must be empty.
|
||||
ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Pending,
|
||||
createdAt: now.AddMinutes(-5), sourceNode: null));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var repo = new NotificationOutboxRepository(ctx);
|
||||
var result = await repo.ComputePerNodeKpisAsync(
|
||||
stuckCutoff: now.AddMinutes(-10), deliveredSince: now.AddMinutes(-30));
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputePerNodeKpisAsync_ReturnsEmpty_WhenNoNotifications()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
var repo = new NotificationOutboxRepository(ctx);
|
||||
var result = await repo.ComputePerNodeKpisAsync(
|
||||
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(-30));
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputePerNodeKpisAsync_OldestPendingAge_ReflectsOlderRow()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// node-a: pending 90m ago, retrying 40m ago.
|
||||
// OldestPendingAge must reflect the 90m row.
|
||||
ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Pending,
|
||||
createdAt: now.AddMinutes(-90), sourceNode: "node-a"));
|
||||
ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Retrying,
|
||||
createdAt: now.AddMinutes(-40), sourceNode: "node-a"));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var repo = new NotificationOutboxRepository(ctx);
|
||||
var result = await repo.ComputePerNodeKpisAsync(
|
||||
stuckCutoff: now.AddMinutes(-10), deliveredSince: now.AddMinutes(-30));
|
||||
|
||||
var a = result.Single(n => n.SourceNode == "node-a");
|
||||
Assert.Equal(2, a.QueueDepth);
|
||||
Assert.Equal(2, a.StuckCount);
|
||||
Assert.NotNull(a.OldestPendingAge);
|
||||
Assert.True(a.OldestPendingAge >= TimeSpan.FromMinutes(85),
|
||||
$"expected OldestPendingAge >= 85m, got {a.OldestPendingAge}");
|
||||
Assert.True(a.OldestPendingAge < TimeSpan.FromMinutes(95),
|
||||
$"expected OldestPendingAge < 95m, got {a.OldestPendingAge}");
|
||||
}
|
||||
}
|
||||
+48
@@ -497,6 +497,54 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
Assert.Null(b.OldestPendingAge);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ComputePerNodeKpisAsync_ScopesCountsToEachNode()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Use unique site + node combos to isolate from other tests running
|
||||
// concurrently on the shared MsSql fixture.
|
||||
var nodeId = "node-b3-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
var nodeB = nodeId + "-b";
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var stuckCutoff = now.AddMinutes(-10);
|
||||
var intervalSince = now.AddHours(-1);
|
||||
|
||||
// nodeId: 2 buffered (one stuck), 1 parked.
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Attempted",
|
||||
createdAtUtc: now.AddMinutes(-30), sourceNode: nodeId));
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Attempted",
|
||||
createdAtUtc: now.AddMinutes(-2), sourceNode: nodeId));
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Parked",
|
||||
createdAtUtc: now.AddMinutes(-5), terminal: true, sourceNode: nodeId));
|
||||
// nodeB: 1 delivered within interval only.
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Delivered",
|
||||
createdAtUtc: now.AddMinutes(-4), updatedAtUtc: now.AddMinutes(-1),
|
||||
terminal: true, terminalAtUtc: now.AddMinutes(-1), sourceNode: nodeB));
|
||||
// Null SourceNode row — must NOT appear in per-node results.
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), status: "Attempted",
|
||||
createdAtUtc: now.AddMinutes(-3), sourceNode: null));
|
||||
|
||||
var perNode = await repo.ComputePerNodeKpisAsync(stuckCutoff, intervalSince);
|
||||
|
||||
var na = Assert.Single(perNode, n => n.SourceNode == nodeId);
|
||||
Assert.Equal(2, na.BufferedCount);
|
||||
Assert.Equal(1, na.ParkedCount);
|
||||
Assert.Equal(1, na.StuckCount);
|
||||
Assert.NotNull(na.OldestPendingAge);
|
||||
|
||||
var nb = Assert.Single(perNode, n => n.SourceNode == nodeB);
|
||||
Assert.Equal(0, nb.BufferedCount);
|
||||
Assert.Equal(1, nb.DeliveredLastInterval);
|
||||
Assert.Null(nb.OldestPendingAge);
|
||||
|
||||
// Null-node row must be absent.
|
||||
Assert.DoesNotContain(perNode, n => n.SourceNode is null);
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private ScadaBridgeDbContext CreateContext()
|
||||
|
||||
@@ -1022,4 +1022,429 @@ public class AuditWriteMiddlewareTests
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(requestJson, evt.RequestSummary);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// M5.3 (T7) Increment 1: Request headers in Extra JSON
|
||||
// Request headers are captured into the Extra JSON object alongside the
|
||||
// existing remoteIp / userAgent fields. Sensitive headers (e.g.
|
||||
// Authorization, X-Api-Key) are redacted to "<redacted>" using the same
|
||||
// HeaderRedactList as ScadaBridgeAuditRedactor.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RequestHeaders_AppearInExtra_UnderRequestHeadersKey()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
ctx.Request.Headers["X-Custom-Header"] = "custom-value";
|
||||
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.NotNull(evt.Extra);
|
||||
using var doc = JsonDocument.Parse(evt.Extra!);
|
||||
var root = doc.RootElement;
|
||||
// Extra must carry a requestHeaders object.
|
||||
Assert.True(root.TryGetProperty("requestHeaders", out var headers),
|
||||
"Extra JSON must contain a 'requestHeaders' property");
|
||||
Assert.Equal(JsonValueKind.Object, headers.ValueKind);
|
||||
// The non-sensitive custom header must appear unredacted.
|
||||
Assert.True(headers.TryGetProperty("X-Custom-Header", out var customVal),
|
||||
"requestHeaders must contain 'X-Custom-Header'");
|
||||
Assert.Equal("custom-value", customVal.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestHeaders_AuthorizationHeader_IsRedacted()
|
||||
{
|
||||
// Authorization is in the default HeaderRedactList and must appear as
|
||||
// "<redacted>" rather than the real token value.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
ctx.Request.Headers["Authorization"] = "Bearer secret-token-abc";
|
||||
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.NotNull(evt.Extra);
|
||||
using var doc = JsonDocument.Parse(evt.Extra!);
|
||||
var root = doc.RootElement;
|
||||
var headers = root.GetProperty("requestHeaders");
|
||||
Assert.True(headers.TryGetProperty("Authorization", out var authVal),
|
||||
"requestHeaders must contain 'Authorization'");
|
||||
Assert.Equal("<redacted>", authVal.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestHeaders_XApiKeyHeader_IsRedacted()
|
||||
{
|
||||
// X-Api-Key is in the default HeaderRedactList and must be redacted.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
ctx.Request.Headers["X-Api-Key"] = "sbk_12345_secretkey";
|
||||
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.NotNull(evt.Extra);
|
||||
using var doc = JsonDocument.Parse(evt.Extra!);
|
||||
var root = doc.RootElement;
|
||||
var headers = root.GetProperty("requestHeaders");
|
||||
Assert.True(headers.TryGetProperty("X-Api-Key", out var keyVal));
|
||||
Assert.Equal("<redacted>", keyVal.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestHeaders_CustomRedactListEntry_IsRedacted()
|
||||
{
|
||||
// A non-default entry added to HeaderRedactList must also be redacted.
|
||||
var opts = new AuditLogOptions
|
||||
{
|
||||
HeaderRedactList = new List<string>
|
||||
{
|
||||
"Authorization", "X-Api-Key", "Cookie", "Set-Cookie",
|
||||
"X-Internal-Secret", // custom addition
|
||||
},
|
||||
};
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
ctx.Request.Headers["X-Internal-Secret"] = "my-secret-value";
|
||||
ctx.Request.Headers["X-Safe-Header"] = "safe-value";
|
||||
|
||||
var mw = CreateMiddleware(
|
||||
_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
writer,
|
||||
options: opts);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
using var doc = JsonDocument.Parse(evt.Extra!);
|
||||
var headers = doc.RootElement.GetProperty("requestHeaders");
|
||||
Assert.Equal("<redacted>", headers.GetProperty("X-Internal-Secret").GetString());
|
||||
Assert.Equal("safe-value", headers.GetProperty("X-Safe-Header").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestHeaders_Redaction_IsCaseInsensitive()
|
||||
{
|
||||
// HeaderRedactList match must be case-insensitive (mirrors the
|
||||
// ScadaBridgeAuditRedactor behaviour — the redact set uses
|
||||
// OrdinalIgnoreCase).
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
// Vary the casing from the list entry ("Authorization").
|
||||
ctx.Request.Headers["authorization"] = "Bearer lower-case-token";
|
||||
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
}, writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
using var doc = JsonDocument.Parse(evt.Extra!);
|
||||
var headers = doc.RootElement.GetProperty("requestHeaders");
|
||||
// ASP.NET Core normalises the header name to "authorization" in the dict;
|
||||
// the redact set (OrdinalIgnoreCase) must still match it.
|
||||
Assert.Equal("<redacted>", headers.GetProperty("authorization").GetString());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// M5.3 (T7) Increment 2: AuditInboundCeilingHits counter
|
||||
// When request OR response exceeds InboundMaxBytes, the middleware
|
||||
// increments IAuditInboundCeilingHitsCounter once per request.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IAuditInboundCeilingHitsCounter"/> that records
|
||||
/// every <see cref="Increment"/> call.
|
||||
/// </summary>
|
||||
private sealed class RecordingCeilingHitsCounter : ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter
|
||||
{
|
||||
private int _count;
|
||||
public int Count => Volatile.Read(ref _count);
|
||||
public void Increment() => Interlocked.Increment(ref _count);
|
||||
}
|
||||
|
||||
private static AuditWriteMiddleware CreateMiddlewareWithCounter(
|
||||
RequestDelegate next,
|
||||
ICentralAuditWriter writer,
|
||||
AuditLogOptions? options,
|
||||
ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter counter) =>
|
||||
new(
|
||||
next,
|
||||
writer,
|
||||
NullLogger<AuditWriteMiddleware>.Instance,
|
||||
new StaticAuditLogOptionsMonitor(options ?? new AuditLogOptions()),
|
||||
actorAccessor: null,
|
||||
ceilingHitsCounter: counter);
|
||||
|
||||
[Fact]
|
||||
public async Task RequestBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter()
|
||||
{
|
||||
const int cap = 1024;
|
||||
var bigBody = new string('x', cap + 100);
|
||||
var writer = new RecordingAuditWriter();
|
||||
var counter = new RecordingCeilingHitsCounter();
|
||||
var ctx = BuildContext(body: bigBody);
|
||||
var mw = CreateMiddlewareWithCounter(
|
||||
hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
writer,
|
||||
options: new AuditLogOptions { InboundMaxBytes = cap },
|
||||
counter: counter);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
Assert.Equal(1, counter.Count);
|
||||
// Verify the truncation did happen to confirm ceiling was hit.
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.True(evt.PayloadTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResponseBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter()
|
||||
{
|
||||
const int cap = 1024;
|
||||
var bigResponse = new string('y', cap + 100);
|
||||
var writer = new RecordingAuditWriter();
|
||||
var counter = new RecordingCeilingHitsCounter();
|
||||
var ctx = BuildContext();
|
||||
ctx.Response.Body = new MemoryStream();
|
||||
|
||||
var mw = CreateMiddlewareWithCounter(
|
||||
async hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
await hc.Response.WriteAsync(bigResponse);
|
||||
},
|
||||
writer,
|
||||
options: new AuditLogOptions { InboundMaxBytes = cap },
|
||||
counter: counter);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
Assert.Equal(1, counter.Count);
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.True(evt.PayloadTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NormalRequest_WithinCap_DoesNotIncrementCeilingHitsCounter()
|
||||
{
|
||||
var writer = new RecordingAuditWriter();
|
||||
var counter = new RecordingCeilingHitsCounter();
|
||||
var smallBody = "{\"ok\":true}";
|
||||
var ctx = BuildContext(body: smallBody);
|
||||
// Cap is well above the body size.
|
||||
var mw = CreateMiddlewareWithCounter(
|
||||
hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
writer,
|
||||
options: new AuditLogOptions { InboundMaxBytes = 8192 },
|
||||
counter: counter);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
Assert.Equal(0, counter.Count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// M5.3 (T7) Increment 3: SkipBodyCapture per-method opt-out
|
||||
// A target with SkipBodyCapture=true produces an audit row with
|
||||
// headers/metadata but empty/omitted body. A normal target still captures.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private static DefaultHttpContext BuildContextWithRoute(
|
||||
string methodName,
|
||||
string? body = null)
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Request.Method = "POST";
|
||||
ctx.Request.Path = $"/api/{methodName}";
|
||||
ctx.Request.RouteValues["methodName"] = methodName;
|
||||
ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("10.0.0.1");
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(body);
|
||||
ctx.Request.Body = new MemoryStream(bytes);
|
||||
ctx.Request.ContentLength = bytes.Length;
|
||||
ctx.Request.ContentType = "application/json";
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipBodyCapture_True_AuditRowEmitted_ButBodyIsNull()
|
||||
{
|
||||
// A target with SkipBodyCapture=true must produce an audit row (the
|
||||
// row must not be suppressed entirely) but RequestSummary and
|
||||
// ResponseSummary must both be null — only the body is omitted.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var opts = new AuditLogOptions
|
||||
{
|
||||
PerTargetOverrides = new Dictionary<string, ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride>
|
||||
{
|
||||
["secret-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride
|
||||
{
|
||||
SkipBodyCapture = true,
|
||||
},
|
||||
},
|
||||
};
|
||||
var ctx = BuildContextWithRoute("secret-method", body: "{\"sensitive\":\"data\"}");
|
||||
|
||||
var mw = CreateMiddleware(
|
||||
async hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
await hc.Response.WriteAsync("{\"result\":\"secret\"}");
|
||||
},
|
||||
writer,
|
||||
options: opts);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
// Row IS emitted — only the body content is suppressed.
|
||||
Assert.Equal("secret-method", evt.Target);
|
||||
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||
// Bodies are null — SkipBodyCapture stripped them.
|
||||
Assert.Null(evt.RequestSummary);
|
||||
Assert.Null(evt.ResponseSummary);
|
||||
// Headers / metadata are still present.
|
||||
Assert.NotNull(evt.Extra);
|
||||
using var doc = JsonDocument.Parse(evt.Extra!);
|
||||
Assert.True(doc.RootElement.TryGetProperty("requestHeaders", out _),
|
||||
"Headers must be present even when body capture is skipped");
|
||||
Assert.Equal(200, evt.HttpStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipBodyCapture_True_CeilingHitsCounter_NotIncremented()
|
||||
{
|
||||
// When SkipBodyCapture=true the body is never measured against the cap;
|
||||
// the counter must NOT be bumped even if the body would have exceeded it.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var counter = new RecordingCeilingHitsCounter();
|
||||
const int cap = 64;
|
||||
var bigBody = new string('z', cap + 1000);
|
||||
var opts = new AuditLogOptions
|
||||
{
|
||||
InboundMaxBytes = cap,
|
||||
PerTargetOverrides = new Dictionary<string, ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride>
|
||||
{
|
||||
["large-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride
|
||||
{
|
||||
SkipBodyCapture = true,
|
||||
},
|
||||
},
|
||||
};
|
||||
var ctx = BuildContextWithRoute("large-method", body: bigBody);
|
||||
|
||||
var mw = CreateMiddlewareWithCounter(
|
||||
hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
writer,
|
||||
options: opts,
|
||||
counter: counter);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
Assert.Equal(0, counter.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipBodyCapture_False_NormalTarget_StillCapturesBody()
|
||||
{
|
||||
// Regression: a target WITHOUT SkipBodyCapture (or with SkipBodyCapture=false)
|
||||
// must still capture the body normally.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var opts = new AuditLogOptions
|
||||
{
|
||||
PerTargetOverrides = new Dictionary<string, ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride>
|
||||
{
|
||||
["normal-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride
|
||||
{
|
||||
SkipBodyCapture = false,
|
||||
},
|
||||
},
|
||||
};
|
||||
var requestJson = "{\"a\":1}";
|
||||
var ctx = BuildContextWithRoute("normal-method", body: requestJson);
|
||||
|
||||
var mw = CreateMiddleware(
|
||||
async hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
await hc.Response.WriteAsync("{\"result\":1}");
|
||||
},
|
||||
writer,
|
||||
options: opts);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(requestJson, evt.RequestSummary);
|
||||
Assert.Equal("{\"result\":1}", evt.ResponseSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipBodyCapture_NoOverride_DefaultTarget_StillCapturesBody()
|
||||
{
|
||||
// A target with no per-target override at all must still capture the body —
|
||||
// SkipBodyCapture defaults to false and must not suppress capture.
|
||||
var writer = new RecordingAuditWriter();
|
||||
var requestJson = "{\"x\":99}";
|
||||
var ctx = BuildContext(body: requestJson);
|
||||
|
||||
var mw = CreateMiddleware(
|
||||
async hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
await hc.Response.WriteAsync("{\"y\":99}");
|
||||
},
|
||||
writer);
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(requestJson, evt.RequestSummary);
|
||||
Assert.Equal("{\"y\":99}", evt.ResponseSummary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
@@ -139,6 +140,116 @@ public class RouteHelperTests
|
||||
Assert.Equal("read failed", ex.Message);
|
||||
}
|
||||
|
||||
// --- WaitForAttribute (spec §6) ---
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_Matched_ReturnsTrue()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToWaitForAttributeResponse(
|
||||
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
|
||||
Matched: true, Value: true, Quality: "Good", TimedOut: false,
|
||||
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
|
||||
|
||||
var matched = await CreateHelper().To("inst-1")
|
||||
.WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.True(matched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_TimedOut_ReturnsFalse()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToWaitForAttributeResponse(
|
||||
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
|
||||
Matched: false, Value: null, Quality: null, TimedOut: true,
|
||||
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
|
||||
|
||||
var matched = await CreateHelper().To("inst-1")
|
||||
.WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.False(matched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_RoutingFailure_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Success=false is a routing-level outcome (e.g. instance not found on the
|
||||
// site), distinct from the wait outcome (Matched/TimedOut).
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToWaitForAttributeResponse(
|
||||
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
|
||||
Matched: false, Value: null, Quality: null, TimedOut: false,
|
||||
Success: false, ErrorMessage: "instance not found", DateTimeOffset.UtcNow));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => CreateHelper().To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30)));
|
||||
Assert.Equal("instance not found", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_EncodesTargetValue_OnRequest()
|
||||
{
|
||||
// Value-equality only across the wire: the target value is encoded via the
|
||||
// canonical AttributeValueCodec, identical to how attribute values travel.
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
RouteToWaitForAttributeRequest? captured = null;
|
||||
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Do<RouteToWaitForAttributeRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToWaitForAttributeResponse(
|
||||
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
|
||||
Matched: true, Value: true, Quality: "Good", TimedOut: false,
|
||||
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
|
||||
|
||||
await CreateHelper().To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal("Flag", captured!.AttributeName);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), captured.Timeout);
|
||||
Assert.Equal(AttributeValueCodec.Encode(true), captured.TargetValueEncoded);
|
||||
Assert.True(Guid.TryParse(captured.CorrelationId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_WithNoExplicitToken_InheritsMethodDeadlineToken()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
using var deadline = new CancellationTokenSource();
|
||||
CancellationToken seen = default;
|
||||
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Any<RouteToWaitForAttributeRequest>(), Arg.Do<CancellationToken>(t => seen = t))
|
||||
.Returns(ci => new RouteToWaitForAttributeResponse(
|
||||
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
|
||||
Matched: false, Value: null, Quality: null, TimedOut: true,
|
||||
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
|
||||
|
||||
var bound = CreateHelper().WithDeadline(deadline.Token);
|
||||
await bound.To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.Equal(deadline.Token, seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAttribute_WithParentExecutionId_CarriesItOnRequest()
|
||||
{
|
||||
SiteResolves("inst-1", "SiteA");
|
||||
var inboundExecutionId = Guid.NewGuid();
|
||||
RouteToWaitForAttributeRequest? captured = null;
|
||||
_router.RouteToWaitForAttributeAsync("SiteA", Arg.Do<RouteToWaitForAttributeRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToWaitForAttributeResponse(
|
||||
((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
|
||||
Matched: true, Value: true, Quality: "Good", TimedOut: false,
|
||||
Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
|
||||
|
||||
var bound = CreateHelper().WithParentExecutionId(inboundExecutionId);
|
||||
await bound.To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
// --- SetAttribute(s) ---
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -89,6 +89,14 @@ public class SiteAuditPushFlowTests : TestKit
|
||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<long> PurgeChannelOlderThanAsync(
|
||||
string channel, DateTime threshold, int batchSize, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<long> BackfillSourceNodeAsync(
|
||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
@@ -610,4 +610,366 @@ public class AuditEndpointsTests
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// /api/audit/tree
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds a TestServer with the audit-log endpoints wired up and the repository
|
||||
/// stub returning the supplied <paramref name="treeNodes"/> for
|
||||
/// <c>GetExecutionTreeAsync</c>.
|
||||
/// </summary>
|
||||
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostWithTreeAsync(
|
||||
string[] roles,
|
||||
IReadOnlyList<ExecutionTreeNode>? treeNodes = null)
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
|
||||
// Default QueryAsync stub so the shared host initialisation does not fail.
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var returnNodes = treeNodes ?? Array.Empty<ExecutionTreeNode>();
|
||||
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(returnNodes));
|
||||
|
||||
var ldap = Substitute.For<ILdapAuthService>();
|
||||
ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(LdapAuthResult.Success("auditor", "Auditor", new[] { "audit" }));
|
||||
|
||||
var roleMapper = Substitute.For<RoleMapper>(Substitute.For<ISecurityRepository>());
|
||||
roleMapper.MapGroupsToRolesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new RoleMappingResult(roles, Array.Empty<string>(), IsSystemWideDeployment: true));
|
||||
|
||||
var hostBuilder = new HostBuilder()
|
||||
.ConfigureWebHost(web =>
|
||||
{
|
||||
web.UseTestServer();
|
||||
web.ConfigureServices(services =>
|
||||
{
|
||||
services.AddRouting();
|
||||
services.AddSingleton(repo);
|
||||
services.AddSingleton(ldap);
|
||||
services.AddSingleton(roleMapper);
|
||||
});
|
||||
web.Configure(app =>
|
||||
{
|
||||
app.UseRouting();
|
||||
app.UseEndpoints(endpoints => endpoints.MapAuditAPI());
|
||||
});
|
||||
});
|
||||
|
||||
var host = await hostBuilder.StartAsync();
|
||||
return (host.GetTestClient(), repo, host);
|
||||
}
|
||||
|
||||
private static ExecutionTreeNode MakeNode(Guid id, Guid? parentId = null, int rowCount = 2) =>
|
||||
new ExecutionTreeNode(
|
||||
ExecutionId: id,
|
||||
ParentExecutionId: parentId,
|
||||
RowCount: rowCount,
|
||||
Channels: new[] { "ApiOutbound" },
|
||||
Statuses: new[] { "Delivered" },
|
||||
SourceSiteId: "plant-a",
|
||||
SourceInstanceId: "inst-1",
|
||||
FirstOccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||||
LastOccurredAtUtc: new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc));
|
||||
|
||||
[Fact]
|
||||
public async Task Tree_ValidExecutionId_ReturnsJsonArray()
|
||||
{
|
||||
var root = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001");
|
||||
var child = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002");
|
||||
var nodes = new[]
|
||||
{
|
||||
MakeNode(root),
|
||||
MakeNode(child, parentId: root),
|
||||
};
|
||||
|
||||
var (client, repo, host) = await BuildHostWithTreeAsync(
|
||||
roles: new[] { "Administrator" },
|
||||
treeNodes: nodes);
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get($"/api/audit/tree?executionId={root:D}"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType);
|
||||
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(2, doc.RootElement.GetArrayLength());
|
||||
|
||||
await repo.Received(1).GetExecutionTreeAsync(root, Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tree_RepoReturnsEmpty_ReturnsEmptyArray()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var (client, _, host) = await BuildHostWithTreeAsync(
|
||||
roles: new[] { "Administrator" },
|
||||
treeNodes: Array.Empty<ExecutionTreeNode>());
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get($"/api/audit/tree?executionId={id:D}"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tree_MissingExecutionId_Returns400()
|
||||
{
|
||||
var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Administrator" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/tree"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tree_InvalidExecutionId_Returns400()
|
||||
{
|
||||
var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Administrator" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/tree?executionId=not-a-guid"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("BAD_REQUEST", body);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tree_WithoutOperationalAudit_Returns403()
|
||||
{
|
||||
var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Designer" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get($"/api/audit/tree?executionId={Guid.NewGuid():D}"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tree_WithoutCredentials_Returns401()
|
||||
{
|
||||
var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Administrator" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get($"/api/audit/tree?executionId={Guid.NewGuid():D}", credential: ""));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tree_ViewerRole_IsAllowed()
|
||||
{
|
||||
var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Viewer" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get($"/api/audit/tree?executionId={Guid.NewGuid():D}"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// POST /api/audit/backfill-source-node (M5.6 T5)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostWithBackfillAsync(
|
||||
string[] roles,
|
||||
long backfillResult = 42L,
|
||||
bool ldapSucceeds = true)
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
repo.BackfillSourceNodeAsync(
|
||||
Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(backfillResult));
|
||||
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.ExecutionTreeNode>>(
|
||||
Array.Empty<ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.ExecutionTreeNode>()));
|
||||
|
||||
var ldap = Substitute.For<ILdapAuthService>();
|
||||
ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ldapSucceeds
|
||||
? LdapAuthResult.Success("auditor", "Auditor", new[] { "audit" })
|
||||
: LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
||||
|
||||
var roleMapper = Substitute.For<RoleMapper>(Substitute.For<ISecurityRepository>());
|
||||
roleMapper.MapGroupsToRolesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new RoleMappingResult(roles, Array.Empty<string>(), IsSystemWideDeployment: true));
|
||||
|
||||
var hostBuilder = new HostBuilder()
|
||||
.ConfigureWebHost(web =>
|
||||
{
|
||||
web.UseTestServer();
|
||||
web.ConfigureServices(services =>
|
||||
{
|
||||
services.AddRouting();
|
||||
services.AddSingleton(repo);
|
||||
services.AddSingleton(ldap);
|
||||
services.AddSingleton(roleMapper);
|
||||
});
|
||||
web.Configure(app =>
|
||||
{
|
||||
app.UseRouting();
|
||||
app.UseEndpoints(endpoints => endpoints.MapAuditAPI());
|
||||
});
|
||||
});
|
||||
|
||||
var host = await hostBuilder.StartAsync();
|
||||
return (host.GetTestClient(), repo, host);
|
||||
}
|
||||
|
||||
private static HttpRequestMessage Post(string url, string body, string credential = BasicCredential)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
if (credential.Length > 0)
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(credential)));
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_AdminRole_Returns200WithRowCount()
|
||||
{
|
||||
var (client, _, host) = await BuildHostWithBackfillAsync(
|
||||
roles: new[] { "Administrator" }, backfillResult: 12345L);
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Post(
|
||||
"/api/audit/backfill-source-node",
|
||||
"{\"sentinel\":\"unknown\",\"before\":\"2026-01-01T00:00:00Z\"}"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var root = doc.RootElement;
|
||||
Assert.Equal(12345L, root.GetProperty("rowsUpdated").GetInt64());
|
||||
Assert.Equal("unknown", root.GetProperty("sentinel").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_ViewerRole_Returns403()
|
||||
{
|
||||
// Viewer has OperationalAudit but NOT the Admin-only backfill permission.
|
||||
var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Viewer" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Post(
|
||||
"/api/audit/backfill-source-node",
|
||||
"{\"sentinel\":\"unknown\",\"before\":\"2026-01-01T00:00:00Z\"}"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_NoCredentials_Returns401()
|
||||
{
|
||||
var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Administrator" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Post(
|
||||
"/api/audit/backfill-source-node",
|
||||
"{\"sentinel\":\"unknown\",\"before\":\"2026-01-01T00:00:00Z\"}",
|
||||
credential: ""));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_MissingBefore_Returns400()
|
||||
{
|
||||
var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Administrator" });
|
||||
using (host)
|
||||
{
|
||||
// No "before" field — required.
|
||||
var response = await client.SendAsync(Post(
|
||||
"/api/audit/backfill-source-node",
|
||||
"{\"sentinel\":\"unknown\"}"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_InvalidBeforeDate_Returns400()
|
||||
{
|
||||
var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Administrator" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Post(
|
||||
"/api/audit/backfill-source-node",
|
||||
"{\"sentinel\":\"unknown\",\"before\":\"not-a-date\"}"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_CustomSentinelAndBatch_PassedToRepo()
|
||||
{
|
||||
var (client, repo, host) = await BuildHostWithBackfillAsync(
|
||||
roles: new[] { "Administrator" }, backfillResult: 7L);
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Post(
|
||||
"/api/audit/backfill-source-node",
|
||||
"{\"sentinel\":\"pre-feature\",\"before\":\"2026-01-01T00:00:00Z\",\"batchSize\":2000}"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
await repo.Received(1).BackfillSourceNodeAsync(
|
||||
"pre-feature",
|
||||
Arg.Is<DateTime>(d => d.Year == 2026 && d.Month == 1 && d.Day == 1),
|
||||
2000,
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackfillSourceNode_DefaultSentinel_IsUnknown_WhenOmitted()
|
||||
{
|
||||
var (client, repo, host) = await BuildHostWithBackfillAsync(
|
||||
roles: new[] { "Administrator" }, backfillResult: 0L);
|
||||
using (host)
|
||||
{
|
||||
// Omit "sentinel" — endpoint defaults to "unknown".
|
||||
var response = await client.SendAsync(Post(
|
||||
"/api/audit/backfill-source-node",
|
||||
"{\"before\":\"2026-01-01T00:00:00Z\"}"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
await repo.Received(1).BackfillSourceNodeAsync(
|
||||
"unknown",
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -495,4 +495,50 @@ public class NotificationOutboxActorQueryTests : TestKit
|
||||
Assert.Contains("db down", response.ErrorMessage);
|
||||
Assert.Empty(response.Sites);
|
||||
}
|
||||
|
||||
// ── Per-node KPI (T6: M5.2 per-node stuck-count KPIs) ──────────────────
|
||||
|
||||
[Fact]
|
||||
public void PerNodeKpiRequest_RepliesWithPerNodeSnapshots()
|
||||
{
|
||||
_repository.ComputePerNodeKpisAsync(
|
||||
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<NodeNotificationKpiSnapshot>
|
||||
{
|
||||
new("node-a", QueueDepth: 3, StuckCount: 1, ParkedCount: 0,
|
||||
DeliveredLastInterval: 5, OldestPendingAge: TimeSpan.FromMinutes(12)),
|
||||
});
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new PerNodeNotificationKpiRequest("corr-pn"), TestActor);
|
||||
|
||||
var response = ExpectMsg<PerNodeNotificationKpiResponse>();
|
||||
Assert.True(response.Success);
|
||||
Assert.Null(response.ErrorMessage);
|
||||
Assert.Equal("corr-pn", response.CorrelationId);
|
||||
Assert.Single(response.Nodes);
|
||||
Assert.Equal("node-a", response.Nodes[0].SourceNode);
|
||||
Assert.Equal(1, response.Nodes[0].StuckCount);
|
||||
|
||||
_repository.Received(1).ComputePerNodeKpisAsync(
|
||||
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerNodeKpiRequest_RepositoryFault_RepliesUnsuccessful()
|
||||
{
|
||||
_repository.ComputePerNodeKpisAsync(
|
||||
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("node-kpi db down"));
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new PerNodeNotificationKpiRequest("corr-pn"), TestActor);
|
||||
|
||||
var response = ExpectMsg<PerNodeNotificationKpiResponse>();
|
||||
Assert.False(response.Success);
|
||||
Assert.Equal("corr-pn", response.CorrelationId);
|
||||
Assert.NotNull(response.ErrorMessage);
|
||||
Assert.Contains("node-kpi db down", response.ErrorMessage);
|
||||
Assert.Empty(response.Nodes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,6 +594,43 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
Assert.NotNull(response.OldestPendingAge);
|
||||
}
|
||||
|
||||
// ── Per-node KPI (T6: M5.2 per-node stuck-count KPIs) ──────────────────
|
||||
|
||||
[SkippableFact]
|
||||
public async Task PerNodeSiteCallKpiRequest_ScopesCountsToEachNode()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var nodeId = "node-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
var actor = CreateActor(repo, new SiteCallAuditOptions
|
||||
{
|
||||
StuckAgeThreshold = TimeSpan.FromMinutes(10),
|
||||
KpiInterval = TimeSpan.FromHours(1),
|
||||
});
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var siteId = NewSiteId();
|
||||
// Non-terminal Attempted, created 30 min ago — buffered + stuck.
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteId, status: "Attempted",
|
||||
createdAtUtc: now.AddMinutes(-30), sourceNode: nodeId));
|
||||
// Terminal Parked.
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteId, status: "Parked",
|
||||
createdAtUtc: now.AddMinutes(-5), terminal: true, sourceNode: nodeId));
|
||||
|
||||
actor.Tell(new PerNodeSiteCallKpiRequest("corr-pnk"), TestActor);
|
||||
|
||||
var response = ExpectMsg<PerNodeSiteCallKpiResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.True(response.Success);
|
||||
|
||||
var myNode = Assert.Single(response.Nodes, n => n.SourceNode == nodeId);
|
||||
Assert.Equal(1, myNode.BufferedCount);
|
||||
Assert.Equal(1, myNode.ParkedCount);
|
||||
Assert.Equal(1, myNode.StuckCount);
|
||||
Assert.NotNull(myNode.OldestPendingAge);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task PerSiteSiteCallKpiRequest_ScopesCountsToEachSite()
|
||||
{
|
||||
@@ -745,6 +782,10 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
|
||||
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -790,5 +831,9 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
|
||||
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerNodeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ public class SiteCallAuditPurgeTests : TestKit
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<SiteCallSiteKpiSnapshot>>(Array.Empty<SiteCallSiteKpiSnapshot>());
|
||||
|
||||
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<SiteCallNodeKpiSnapshot>>(Array.Empty<SiteCallNodeKpiSnapshot>());
|
||||
}
|
||||
|
||||
/// <summary>Repository whose purge always throws — to prove continue-on-error keeps the singleton alive.</summary>
|
||||
@@ -94,6 +98,7 @@ public class SiteCallAuditPurgeTests : TestKit
|
||||
public Task<IReadOnlyList<SiteCall>> QueryAsync(SiteCallQueryFilter f, SiteCallPaging p, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>());
|
||||
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult(new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0));
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCallSiteKpiSnapshot>>(Array.Empty<SiteCallSiteKpiSnapshot>());
|
||||
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCallNodeKpiSnapshot>>(Array.Empty<SiteCallNodeKpiSnapshot>());
|
||||
}
|
||||
|
||||
private IActorRef CreateActor(ISiteCallAuditRepository repo, SiteCallAuditOptions options) =>
|
||||
|
||||
@@ -142,6 +142,10 @@ public class SiteCallAuditReconciliationTests : TestKit
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<SiteCallSiteKpiSnapshot>>(Array.Empty<SiteCallSiteKpiSnapshot>());
|
||||
|
||||
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<SiteCallNodeKpiSnapshot>>(Array.Empty<SiteCallNodeKpiSnapshot>());
|
||||
}
|
||||
|
||||
private IActorRef CreateActor(
|
||||
|
||||
@@ -50,6 +50,10 @@ public class SiteCallRelayTests : TestKit
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("relay must not compute per-site KPIs");
|
||||
|
||||
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("relay must not compute per-node KPIs");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,6 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
@@ -389,6 +390,61 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
||||
Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}");
|
||||
}
|
||||
|
||||
// ── Spec §6 (WD-2b): routed RouteToWaitForAttributeRequest → InstanceActor ──
|
||||
|
||||
[Fact]
|
||||
public async Task RouteInboundApiWaitForAttribute_AttributeAlreadyAtTarget_RepliesMatched()
|
||||
{
|
||||
// A routed wait whose target equals the instance's current (static)
|
||||
// attribute value must satisfy the InstanceActor fast-path and come back
|
||||
// Success:true, Matched:true with the matched value/quality.
|
||||
var actor = CreateDeploymentManager();
|
||||
await Task.Delay(500); // empty startup
|
||||
|
||||
// MakeConfigJson seeds a scalar static attribute "TestAttr" = "42" (Good).
|
||||
actor.Tell(new DeployInstanceCommand(
|
||||
"dep-wait", "WaitPump", "sha256:wait",
|
||||
MakeConfigJson("WaitPump"), "admin", DateTimeOffset.UtcNow));
|
||||
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(1000); // let the InstanceActor spin up + load static attrs
|
||||
|
||||
// Encode the target the same way the InstanceActor encodes the current
|
||||
// value for its codec-equality match (value-equality only across the wire).
|
||||
var encodedTarget = AttributeValueCodec.Encode("42");
|
||||
actor.Tell(new RouteToWaitForAttributeRequest(
|
||||
"wait-corr-1", "WaitPump", "TestAttr", encodedTarget,
|
||||
TimeSpan.FromSeconds(5), DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<RouteToWaitForAttributeResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal("wait-corr-1", response.CorrelationId);
|
||||
Assert.True(response.Success, $"Routed wait failed: {response.ErrorMessage}");
|
||||
Assert.True(response.Matched, "Expected fast-path match (attribute already at target).");
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal("42", response.Value);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RouteInboundApiWaitForAttribute_UnknownInstance_RepliesNotFound()
|
||||
{
|
||||
// A routed wait for an instance that was never deployed to this site must
|
||||
// come back Success:false with a not-found message (routing-level outcome),
|
||||
// mirroring the other RouteTo* unknown-instance paths.
|
||||
var actor = CreateDeploymentManager();
|
||||
await Task.Delay(500);
|
||||
|
||||
actor.Tell(new RouteToWaitForAttributeRequest(
|
||||
"wait-corr-2", "NeverDeployedWait", "TestAttr",
|
||||
AttributeValueCodec.Encode("42"), TimeSpan.FromSeconds(5), DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<RouteToWaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("wait-corr-2", response.CorrelationId);
|
||||
Assert.False(response.Success);
|
||||
Assert.False(response.Matched);
|
||||
Assert.NotNull(response.ErrorMessage);
|
||||
Assert.Contains("not found", response.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// ── M2.11: Debug-view routing — unknown-instance not-found signal ──
|
||||
|
||||
[Fact]
|
||||
|
||||
+853
@@ -0,0 +1,853 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the event-driven <c>WaitForAttribute</c> one-shot waiter registry in
|
||||
/// <see cref="InstanceActor"/> (Attributes.WaitAsync spec §3-§5). Covers the
|
||||
/// fast-path, change-match, timeout, no-leak (timeout-canceled-on-match), and
|
||||
/// predicate-overload acceptance criteria.
|
||||
/// </summary>
|
||||
public class InstanceActorWaitForAttributeTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly string _dbFile;
|
||||
|
||||
public InstanceActorWaitForAttributeTests()
|
||||
{
|
||||
_dbFile = Path.Combine(Path.GetTempPath(), $"instance-waitfor-test-{Guid.NewGuid():N}.db");
|
||||
_storage = new SiteStorageService(
|
||||
$"Data Source={_dbFile}",
|
||||
NullLogger<SiteStorageService>.Instance);
|
||||
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||
_compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
_sharedScriptLibrary = new SharedScriptLibrary(
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
_options = new SiteRuntimeOptions();
|
||||
}
|
||||
|
||||
private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config)
|
||||
{
|
||||
return ActorOf(Props.Create(() => new InstanceActor(
|
||||
instanceName,
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null, // no stream manager in tests
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance)));
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
try { File.Delete(_dbFile); } catch { /* cleanup */ }
|
||||
}
|
||||
|
||||
// ── 1. Fast-path: attribute already at target ────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Acceptance §7.1: when the attribute already equals the target at the time
|
||||
/// the waiter registers, the actor must reply immediately with Matched=true
|
||||
/// (carrying the current value), without scheduling a timeout.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_FastPath_AlreadyAtTarget_RepliesMatchedImmediately()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Flag", Value = "true", DataType = "Boolean" }
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-fast", "Pump1", "Flag",
|
||||
"true", null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal("wfa-fast", response.CorrelationId);
|
||||
Assert.Equal("true", response.Value?.ToString());
|
||||
}
|
||||
|
||||
// ── 2. Change-match: register first, then drive a value change ───────────
|
||||
|
||||
/// <summary>
|
||||
/// Acceptance §7.1/§7.4: registering when the value does NOT match, then
|
||||
/// driving the attribute to the target value (via a DCL TagValueUpdate) must
|
||||
/// produce a single Matched=true reply carrying the new value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_ChangeMatch_RepliesMatchedWithNewValue()
|
||||
{
|
||||
const string tag = "ns=3;s=Recipe.Processed";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Processed", Value = "false", DataType = "Boolean",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"Pump1",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dcl.Ref)));
|
||||
|
||||
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Register: current value "false" does not match the target. The value
|
||||
// arrives from the DCL as a boolean true, whose codec-encoded form is
|
||||
// "True" — so the target must be encoded the same way the accessor would
|
||||
// (AttributeValueCodec.Encode(true)), NOT the literal string "true".
|
||||
var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode(true);
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-change", "Pump1", "Processed",
|
||||
target, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow));
|
||||
|
||||
// No reply yet — the value has not changed to the target.
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Drive the value to the target through the DCL ingest path.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, true, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal("wfa-change", response.CorrelationId);
|
||||
Assert.Equal(true, response.Value);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
}
|
||||
|
||||
// ── 3. Timeout: value never matches ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Acceptance §7.2: when the attribute never reaches the target within the
|
||||
/// timeout, the actor replies Matched=false, TimedOut=true (no throw).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_Timeout_RepliesNotMatchedTimedOut()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Flag", Value = "false", DataType = "Boolean" }
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-timeout", "Pump1", "Flag",
|
||||
"true", null, TimeSpan.FromMilliseconds(300), DateTimeOffset.UtcNow));
|
||||
|
||||
// The scheduled timeout fires; allow a tolerant deadline.
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(3));
|
||||
Assert.False(response.Matched);
|
||||
Assert.True(response.TimedOut);
|
||||
Assert.Equal("wfa-timeout", response.CorrelationId);
|
||||
}
|
||||
|
||||
// ── 4. No-leak: timeout canceled on match (no second reply) ──────────────
|
||||
|
||||
/// <summary>
|
||||
/// Acceptance §7.5: after a successful change-match, the scheduled timeout
|
||||
/// must have been canceled and the waiter removed — so NO second (timeout)
|
||||
/// response arrives after the match.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_Match_CancelsTimeout_NoSecondReply()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Flag", Value = "false", DataType = "Boolean" }
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
// Register with a short timeout, then match BEFORE it would fire.
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-noleak", "Pump1", "Flag",
|
||||
"true", null, TimeSpan.FromMilliseconds(500), DateTimeOffset.UtcNow));
|
||||
|
||||
// Drive the static value to the target; the actor publishes via
|
||||
// HandleAttributeValueChanged, satisfying the waiter.
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"set-flag", "Pump1", "Flag", "true", DateTimeOffset.UtcNow));
|
||||
|
||||
// First reply: the match. (A SetStaticAttributeResponse also arrives for
|
||||
// the set command — filter for the WaitForAttributeResponse.)
|
||||
var matched = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(matched.Matched);
|
||||
Assert.False(matched.TimedOut);
|
||||
|
||||
// The set command's own ack — drain it so the no-msg assert below is clean.
|
||||
ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// No second WaitForAttributeResponse (the timeout was canceled) for longer
|
||||
// than the original 500ms timeout window.
|
||||
ExpectNoMsg(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
// ── 5. Predicate overload ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Acceptance §7 (predicate form): registering with a site-local predicate and
|
||||
/// then flipping the value so the predicate passes must produce Matched=true.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_PredicateOverload_MatchesOnPredicatePass()
|
||||
{
|
||||
const string tag = "ns=3;s=Level";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Level", Value = "0", DataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"Pump1",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dcl.Ref)));
|
||||
|
||||
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Predicate: value > 50 (current is 0, so no immediate match).
|
||||
Func<object?, bool> predicate = v =>
|
||||
v is not null && int.TryParse(v.ToString(), out var n) && n > 50;
|
||||
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-pred", "Pump1", "Level",
|
||||
null, predicate, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow));
|
||||
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// A value below the threshold must NOT satisfy the predicate.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, 25, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// A value above the threshold satisfies it.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, 75, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal(75, response.Value);
|
||||
}
|
||||
|
||||
// ── 6. "any change" (null target + null predicate) ───────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Spec §4.1: a null TargetValueEncoded + null Predicate means "wait for any
|
||||
/// change" (test <c>_ => true</c>). When the attribute ALREADY holds a value at
|
||||
/// registration, the fast-path matches IMMEDIATELY — there is no need to wait for
|
||||
/// a subsequent update. (A separate test covers the absent-at-registration case.)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_AnyChange_MatchesImmediatelyWhenAttributePresent()
|
||||
{
|
||||
const string tag = "ns=3;s=Speed";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Speed", Value = "0", DataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"Pump1",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dcl.Ref)));
|
||||
|
||||
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// "any change" registers with a non-trivial timeout. The fast-path uses
|
||||
// `_ => true`, so a currently-present attribute matches immediately.
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-any", "Pump1", "Speed",
|
||||
null, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow));
|
||||
|
||||
// Speed=0 is already present, so the "any change" test (_ => true) matches
|
||||
// immediately on the fast path.
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spec §4.1 (companion to the immediate-match case): when the attribute is
|
||||
/// ABSENT at registration (no entry in <c>_attributes</c>), the "any change"
|
||||
/// waiter does NOT fast-path — it registers, and a later value update on that
|
||||
/// attribute is the first thing that satisfies it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_AnyChange_AttributeAbsent_MatchesOnLaterSet()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Known", Value = "x", DataType = "String" }
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
// "Ghost" is not a configured attribute, so _attributes has no entry — the
|
||||
// fast-path TryGetValue misses and the waiter registers rather than matching.
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-absent", "Pump1", "Ghost",
|
||||
null, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow));
|
||||
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// A direct AttributeValueChanged for "Ghost" populates _attributes and
|
||||
// re-evaluates the waiter; the any-change test now matches the new value.
|
||||
actor.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Ghost", "Ghost", "appeared", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal("wfa-absent", response.CorrelationId);
|
||||
Assert.Equal("appeared", response.Value);
|
||||
}
|
||||
|
||||
// ── 7. CRITICAL 1: no spurious match on a quality-only republish ─────────
|
||||
|
||||
/// <summary>
|
||||
/// CRITICAL 1 regression: the List-coerce-failure Bad-quality path republishes
|
||||
/// the OLD value (quality flipped to Bad) WITHOUT changing <c>_attributes</c>, so
|
||||
/// it passes <c>evaluateWaiters:false</c> — registered waiters are NOT re-evaluated
|
||||
/// on this non-change republish, must NOT spuriously fire, and must STILL resolve
|
||||
/// on the next genuine value change.
|
||||
///
|
||||
/// <para>
|
||||
/// We register an "any-change" waiter (which correctly fast-path matches the
|
||||
/// present value and is drained) plus a pending predicate waiter that does not yet
|
||||
/// match, then drive the Bad-quality republish and assert NO match is delivered for
|
||||
/// the pending waiter, and that a subsequent REAL change resolves it. (Note: the
|
||||
/// purest "any-change fires on a non-change republish" symptom is not directly
|
||||
/// reproducible — an any-change waiter against a present attribute always fast-path
|
||||
/// matches and so never stays pending across a republish; this test guards the
|
||||
/// republish path against double-firing / stranding waiters and against the
|
||||
/// predicate being re-evaluated on the non-change republish.)
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_BadQualityRepublish_NoValueChange_DoesNotMatch()
|
||||
{
|
||||
const string tag = "ns=3;s=Items";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
// Static default {1,2}: a real list value is present from
|
||||
// construction so the Bad-quality republish has an OLD value to
|
||||
// republish. The waiter below targets a DIFFERENT value so it is
|
||||
// genuinely pending (no fast-path match) when the republish fires.
|
||||
CanonicalName = "Items", Value = "[1,2]", DataType = "List",
|
||||
ElementDataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"Pump1",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dcl.Ref)));
|
||||
|
||||
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// A predicate waiter that matches a list of length >= 3. Current value is
|
||||
// {1,2} (length 2) so it does NOT fast-path match — it registers and stays
|
||||
// pending. Crucially, the Bad-quality republish below carries the SAME OLD
|
||||
// value {1,2} (length 2); with the bug (evaluateWaiters always true) the
|
||||
// predicate would be re-evaluated against {1,2} → still false, so this probe
|
||||
// also guards the predicate-isolation contract on the republish path.
|
||||
Func<object?, bool> lenAtLeast3 = v =>
|
||||
v is System.Collections.IList list && list.Count >= 3;
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-len3", "Pump1", "Items",
|
||||
null, lenAtLeast3, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow));
|
||||
|
||||
// Also register an "any-change" waiter while the attribute is present — it
|
||||
// fast-path matches the current {1,2} immediately. Drain that correct match;
|
||||
// it is the documented immediate-match behaviour, not the bug under test.
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-any", "Pump1", "Items",
|
||||
null, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow));
|
||||
var immediate = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("wfa-any", immediate.CorrelationId);
|
||||
Assert.True(immediate.Matched);
|
||||
|
||||
// Drive the List-coerce-FAILURE Bad-quality republish: a scalar int cannot
|
||||
// coerce to List<Int32>, so the actor sets quality Bad and republishes the
|
||||
// OLD value {1,2} WITHOUT changing _attributes (evaluateWaiters:false).
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, 999, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
// The pending length>=3 waiter must NOT fire on this non-change republish.
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
// A REAL change to a length-3 list resolves the still-pending waiter.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, new[] { 7, 8, 9 }, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
var realChange = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("wfa-len3", realChange.CorrelationId);
|
||||
Assert.True(realChange.Matched);
|
||||
Assert.False(realChange.TimedOut);
|
||||
}
|
||||
|
||||
// ── 8. CRITICAL 2: throwing predicate is isolated ────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// CRITICAL 2 regression: two waiters on the SAME attribute — one with a
|
||||
/// predicate that throws, one a normal value-equality. A single value change
|
||||
/// must (a) NOT crash the actor, (b) evict the throwing waiter with a
|
||||
/// non-matched error reply, and (c) STILL resolve the normal sibling. Finally
|
||||
/// the actor must remain responsive to a subsequent request.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_ThrowingPredicate_IsIsolated_SiblingStillMatches()
|
||||
{
|
||||
const string tag = "ns=3;s=State";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "State", Value = "init", DataType = "String",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"Pump1",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dcl.Ref)));
|
||||
|
||||
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Waiter A: predicate that returns false for the CURRENT value ("init") so
|
||||
// it clears the fast-path and registers, but THROWS once the value becomes
|
||||
// "ready" — exercising the resolve-loop guard (not the fast-path guard).
|
||||
Func<object?, bool> boom = v =>
|
||||
v?.ToString() == "ready" ? throw new InvalidOperationException("kaboom") : false;
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-throw", "Pump1", "State",
|
||||
null, boom, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow));
|
||||
|
||||
// Waiter B: normal value-equality waiting for "ready".
|
||||
var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode("ready");
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-normal", "Pump1", "State",
|
||||
target, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow));
|
||||
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
|
||||
// One change to "ready": evaluates BOTH waiters on this attribute. The
|
||||
// throwing one must be evicted (error reply); the normal one must match.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
// Collect the two replies (order is registry-iteration dependent).
|
||||
var r1 = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
var r2 = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
var byId = new[] { r1, r2 }.ToDictionary(r => r.CorrelationId);
|
||||
|
||||
var thrown = byId["wfa-throw"];
|
||||
Assert.False(thrown.Matched);
|
||||
Assert.False(thrown.TimedOut);
|
||||
Assert.NotNull(thrown.ErrorMessage);
|
||||
Assert.Contains("Wait predicate threw", thrown.ErrorMessage);
|
||||
|
||||
var normal = byId["wfa-normal"];
|
||||
Assert.True(normal.Matched);
|
||||
Assert.False(normal.TimedOut);
|
||||
Assert.Equal("ready", normal.Value);
|
||||
|
||||
// The actor stayed alive and responsive: a follow-up request resolves.
|
||||
actor.Tell(new GetAttributeRequest("get-after", "Pump1", "State", DateTimeOffset.UtcNow));
|
||||
var get = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("ready", get.Value);
|
||||
|
||||
// And the throwing waiter was REMOVED (no longer in the registry): driving
|
||||
// another change produces NO further reply for it.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "again", QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
// ── 8b. CRITICAL 2 (fast-path): throwing predicate on already-held value ──
|
||||
|
||||
/// <summary>
|
||||
/// CRITICAL 2 regression (fast-path analogue of
|
||||
/// <see cref="WaitForAttribute_ThrowingPredicate_IsIsolated_SiblingStillMatches"/>):
|
||||
/// a predicate that THROWS is registered against an attribute that ALREADY holds a
|
||||
/// value, so the fast-path <c>test(current)</c> runs and throws. The actor must
|
||||
/// (a) reply a non-matched <c>WaitForAttributeResponse</c> with a non-null
|
||||
/// <c>ErrorMessage</c> (predicate-threw), (b) stay alive/responsive (it answers a
|
||||
/// subsequent <c>GetAttributeRequest</c>), and (c) NOT register the waiter — there
|
||||
/// is no later/second reply even after a value change on that attribute (the
|
||||
/// fast-path guard returns WITHOUT scheduling a timeout or storing the waiter).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_ThrowingPredicate_FastPath_RepliesError_NoRegistration_ActorStaysAlive()
|
||||
{
|
||||
const string tag = "ns=3;s=State";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
// Present from construction so the fast-path TryGetValue HITS and
|
||||
// the predicate runs on the current value (and throws).
|
||||
CanonicalName = "State", Value = "init", DataType = "String",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"Pump1",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dcl.Ref)));
|
||||
|
||||
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Predicate THROWS unconditionally — the current value "init" is already
|
||||
// present, so the fast-path test(current) executes it and throws.
|
||||
Func<object?, bool> boom = _ => throw new InvalidOperationException("kaboom");
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-fp-throw", "Pump1", "State",
|
||||
null, boom, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow));
|
||||
|
||||
// (a) Non-matched error reply (predicate-threw), guarded on the fast-path.
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("wfa-fp-throw", response.CorrelationId);
|
||||
Assert.False(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.NotNull(response.ErrorMessage);
|
||||
Assert.Contains("Wait predicate threw", response.ErrorMessage);
|
||||
|
||||
// (b) The actor stayed alive and responsive: a follow-up request resolves.
|
||||
actor.Tell(new GetAttributeRequest("get-after-fp", "Pump1", "State", DateTimeOffset.UtcNow));
|
||||
var get = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("init", get.Value);
|
||||
|
||||
// (c) The waiter was NOT registered (no timeout scheduled): driving a value
|
||||
// change on "State" produces NO further WaitForAttributeResponse.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
// ── 9. Quality-gated ("Good"-only) matching (spec §4.2) ──────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds a data-connected instance actor with a single attribute backed by a
|
||||
/// DCL probe, draining the initial <c>SubscribeTagsRequest</c>. Used by the
|
||||
/// quality-gate tests, which drive value+quality through the DCL ingest path.
|
||||
/// </summary>
|
||||
private IActorRef CreateDataConnectedActor(
|
||||
string instanceName, string attribute, string tag, string dataType, TestProbe dcl)
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = attribute, Value = "init", DataType = dataType,
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
instanceName,
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dcl.Ref)));
|
||||
|
||||
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||
return actor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spec §4.2 (change-match): with <c>RequireGoodQuality:true</c>, a value that
|
||||
/// reaches the target but arrives at <b>Bad</b> quality is NOT a match — the
|
||||
/// waiter stays pending and times out.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_QualityGated_ChangeMatch_BadQuality_DoesNotMatch_TimesOut()
|
||||
{
|
||||
const string tag = "ns=3;s=State";
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateDataConnectedActor("Pump1", "State", tag, "String", dcl);
|
||||
|
||||
var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode("ready");
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-qg-bad", "Pump1", "State",
|
||||
target, null, TimeSpan.FromMilliseconds(500), DateTimeOffset.UtcNow,
|
||||
RequireGoodQuality: true));
|
||||
|
||||
// Value reaches the target but at Bad quality → must NOT match.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Bad, DateTimeOffset.UtcNow));
|
||||
|
||||
// The only reply must be the timeout (no spurious Bad-quality match).
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(3));
|
||||
Assert.False(response.Matched);
|
||||
Assert.True(response.TimedOut);
|
||||
Assert.Equal("wfa-qg-bad", response.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spec §4.2 (change-match, quality-agnostic baseline): the SAME Bad-quality
|
||||
/// value-reaches-target scenario DOES match when <c>RequireGoodQuality:false</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_QualityAgnostic_ChangeMatch_BadQuality_Matches()
|
||||
{
|
||||
const string tag = "ns=3;s=State";
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateDataConnectedActor("Pump1", "State", tag, "String", dcl);
|
||||
|
||||
var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode("ready");
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-qa-bad", "Pump1", "State",
|
||||
target, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow,
|
||||
RequireGoodQuality: false));
|
||||
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Bad, DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal("wfa-qa-bad", response.CorrelationId);
|
||||
Assert.Equal("ready", response.Value);
|
||||
Assert.Equal("Bad", response.Quality);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spec §4.2 (change-match): with <c>RequireGoodQuality:true</c>, a value that
|
||||
/// reaches the target at <b>Good</b> quality matches normally. Also proves the
|
||||
/// gate is per-quality not per-value: a Bad-quality arrival at the target is
|
||||
/// skipped, then a Good-quality arrival at the target resolves the waiter.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_QualityGated_ChangeMatch_GoodQuality_Matches()
|
||||
{
|
||||
const string tag = "ns=3;s=State";
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateDataConnectedActor("Pump1", "State", tag, "String", dcl);
|
||||
|
||||
var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode("ready");
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-qg-good", "Pump1", "State",
|
||||
target, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow,
|
||||
RequireGoodQuality: true));
|
||||
|
||||
// First arrival at target but Bad quality is skipped (gate holds it pending).
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Bad, DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||
|
||||
// Then a Good-quality arrival at the target resolves it.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal("wfa-qg-good", response.CorrelationId);
|
||||
Assert.Equal("ready", response.Value);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spec §4.2 (fast-path): the attribute ALREADY holds the target value at
|
||||
/// <b>Bad</b> quality when the quality-gated waiter registers. The fast-path must
|
||||
/// NOT reply matched — it registers + schedules the timeout like any pending
|
||||
/// waiter, and (here) times out because the value never reaches target at Good.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_QualityGated_FastPath_AlreadyAtTargetButBad_DoesNotMatch_TimesOut()
|
||||
{
|
||||
const string tag = "ns=3;s=State";
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateDataConnectedActor("Pump1", "State", tag, "String", dcl);
|
||||
|
||||
// Seed the attribute to the target value at Bad quality BEFORE registering.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Bad, DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(200)); // no waiter yet → no reply
|
||||
|
||||
var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode("ready");
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-qg-fp-bad", "Pump1", "State",
|
||||
target, null, TimeSpan.FromMilliseconds(500), DateTimeOffset.UtcNow,
|
||||
RequireGoodQuality: true));
|
||||
|
||||
// Fast-path quality-fail → registers, then times out (no fast matched reply).
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(3));
|
||||
Assert.False(response.Matched);
|
||||
Assert.True(response.TimedOut);
|
||||
Assert.Equal("wfa-qg-fp-bad", response.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spec §4.2 (fast-path, quality-agnostic baseline): the SAME already-at-target-
|
||||
/// but-Bad attribute fast-path MATCHES when <c>RequireGoodQuality:false</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_QualityAgnostic_FastPath_AlreadyAtTargetButBad_Matches()
|
||||
{
|
||||
const string tag = "ns=3;s=State";
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateDataConnectedActor("Pump1", "State", tag, "String", dcl);
|
||||
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Bad, DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
|
||||
var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode("ready");
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-qa-fp-bad", "Pump1", "State",
|
||||
target, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow,
|
||||
RequireGoodQuality: false));
|
||||
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal("wfa-qa-fp-bad", response.CorrelationId);
|
||||
Assert.Equal("ready", response.Value);
|
||||
Assert.Equal("Bad", response.Quality);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spec §4.2 (fast-path): the attribute ALREADY holds the target value at
|
||||
/// <b>Good</b> quality when the quality-gated waiter registers → the fast-path
|
||||
/// matches immediately.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_QualityGated_FastPath_AlreadyAtTargetGood_MatchesImmediately()
|
||||
{
|
||||
const string tag = "ns=3;s=State";
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateDataConnectedActor("Pump1", "State", tag, "String", dcl);
|
||||
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
|
||||
var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode("ready");
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-qg-fp-good", "Pump1", "State",
|
||||
target, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow,
|
||||
RequireGoodQuality: true));
|
||||
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal("wfa-qg-fp-good", response.CorrelationId);
|
||||
Assert.Equal("ready", response.Value);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): nested
|
||||
/// <c>CallScript</c> / <c>CallShared</c> invocations and alarm on-trigger runs
|
||||
/// must form a true execution tree, where each spawned run records its
|
||||
/// immediate spawner's <c>ExecutionId</c> as its <c>ParentExecutionId</c>.
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// A nested <c>CallScript</c> (actor-routed) emits a
|
||||
/// <see cref="ScriptCallRequest"/> whose <c>ParentExecutionId</c> is the
|
||||
/// CALLING run's OWN <c>ExecutionId</c> — NOT the inherited grandparent — so
|
||||
/// <c>A → CallScript(B)</c> yields <c>B.Parent == A.ExecutionId</c>.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// A nested <c>CallShared</c> (inline) runs in a child context that mints a
|
||||
/// fresh <c>ExecutionId</c> and records the caller's <c>ExecutionId</c> as its
|
||||
/// parent — so <c>B → CallShared(C)</c> yields <c>C.Parent == B.ExecutionId</c>
|
||||
/// (and NOT B's inherited parent A), proving a multi-level tree.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// The alarm on-trigger plumbing carries a <c>parentExecutionId</c> into the
|
||||
/// script context — null today (the run is a root) but threaded so a future
|
||||
/// firing id can flow.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class ParentExecutionTreeTests : TestKit
|
||||
{
|
||||
private const string InstanceName = "Plant.Pump42";
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IAuditWriter"/> capturing every emitted event
|
||||
/// (mirrors <c>ExecutionCorrelationContextTests.CapturingAuditWriter</c>).
|
||||
/// </summary>
|
||||
private sealed class CapturingAuditWriter : IAuditWriter
|
||||
{
|
||||
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
|
||||
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
Events.Add(evt.AsRow());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static SharedScriptLibrary NewLibrary()
|
||||
{
|
||||
var compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
return new SharedScriptLibrary(
|
||||
compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a context whose <c>CallScript</c> Ask targets <paramref name="instanceActor"/>
|
||||
/// (a probe), so the forwarded <see cref="ScriptCallRequest"/> can be captured.
|
||||
/// </summary>
|
||||
private static ScriptRuntimeContext CreateContext(
|
||||
IActorRef instanceActor,
|
||||
SharedScriptLibrary library,
|
||||
IExternalSystemClient? externalSystemClient = null,
|
||||
IAuditWriter? auditWriter = null,
|
||||
Guid? executionId = null,
|
||||
Guid? parentExecutionId = null)
|
||||
{
|
||||
return new ScriptRuntimeContext(
|
||||
instanceActor,
|
||||
ActorRefs.Nobody,
|
||||
library,
|
||||
currentCallDepth: 0,
|
||||
maxCallDepth: 10,
|
||||
askTimeout: TimeSpan.FromSeconds(5),
|
||||
instanceName: InstanceName,
|
||||
logger: NullLogger.Instance,
|
||||
externalSystemClient: externalSystemClient,
|
||||
siteId: "site-77",
|
||||
sourceScript: "ScriptActor:A",
|
||||
auditWriter: auditWriter,
|
||||
executionId: executionId,
|
||||
parentExecutionId: parentExecutionId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Nested CallScript (actor-routed) — A → CallScript(B)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task CallScript_StampsCallingRunsOwnExecutionId_AsChildParent()
|
||||
{
|
||||
// A → CallScript(B): the child request's ParentExecutionId must be A's
|
||||
// OWN ExecutionId, forming the A→B tree edge.
|
||||
var probe = CreateTestProbe();
|
||||
var aExecutionId = Guid.NewGuid();
|
||||
var context = CreateContext(probe.Ref, NewLibrary(), executionId: aExecutionId);
|
||||
|
||||
var call = context.CallScript("B");
|
||||
|
||||
var request = probe.ExpectMsg<ScriptCallRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("B", request.ScriptName);
|
||||
// B's parent is A's own execution id — the A→B tree edge.
|
||||
Assert.Equal(aExecutionId, request.ParentExecutionId);
|
||||
|
||||
// Unblock the Ask so the test completes cleanly.
|
||||
probe.Reply(new ScriptCallResult(request.CorrelationId, true, null, null));
|
||||
await call;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallScript_FromRoutedRun_UsesOwnExecutionId_NotInheritedParent()
|
||||
{
|
||||
// A 2-level tree edge: B was itself spawned (it carries a parent = A).
|
||||
// When B does CallScript(C), C.Parent must be B's OWN ExecutionId — NOT
|
||||
// the inherited A. This is the regression that distinguishes a true tree
|
||||
// from a flattened "everything under the original spawner" model.
|
||||
var probe = CreateTestProbe();
|
||||
var bExecutionId = Guid.NewGuid();
|
||||
var aExecutionId = Guid.NewGuid(); // B's inherited parent
|
||||
var context = CreateContext(
|
||||
probe.Ref, NewLibrary(),
|
||||
executionId: bExecutionId,
|
||||
parentExecutionId: aExecutionId);
|
||||
|
||||
var call = context.CallScript("C");
|
||||
|
||||
var request = probe.ExpectMsg<ScriptCallRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(bExecutionId, request.ParentExecutionId);
|
||||
Assert.NotEqual(aExecutionId, request.ParentExecutionId);
|
||||
|
||||
probe.Reply(new ScriptCallResult(request.CorrelationId, true, null, null));
|
||||
await call;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Nested CallShared (inline) — B → CallShared(C)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task CallShared_ChildRun_ParentIsCallersExecutionId_FreshOwnExecutionId()
|
||||
{
|
||||
// B → CallShared(C): the shared script C runs inline but is modelled as
|
||||
// its OWN execution node — a fresh ExecutionId parented to B's
|
||||
// ExecutionId. Asserted via the audit row C emits through
|
||||
// Instance.ExternalSystem.Call.
|
||||
var client = new Mock<IExternalSystemClient>();
|
||||
client
|
||||
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
|
||||
var writer = new CapturingAuditWriter();
|
||||
|
||||
var library = NewLibrary();
|
||||
Assert.True(library.CompileAndRegister(
|
||||
"C", "await Instance.ExternalSystem.Call(\"ERP\", \"GetOrder\"); return null;"));
|
||||
|
||||
var bExecutionId = Guid.NewGuid();
|
||||
var context = CreateContext(
|
||||
ActorRefs.Nobody, library,
|
||||
externalSystemClient: client.Object,
|
||||
auditWriter: writer,
|
||||
executionId: bExecutionId);
|
||||
|
||||
await context.Scripts.CallShared("C");
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
// C's parent is B's execution id — the B→C tree edge.
|
||||
Assert.Equal(bExecutionId, evt.ParentExecutionId);
|
||||
// C minted its OWN fresh, non-empty execution id, distinct from B.
|
||||
Assert.NotNull(evt.ExecutionId);
|
||||
Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
|
||||
Assert.NotEqual(bExecutionId, evt.ExecutionId!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallShared_FromRoutedRun_ChildParentIsCaller_NotInheritedGrandparent()
|
||||
{
|
||||
// Regression / multi-level: B itself carries a parent A. When B does
|
||||
// CallShared(C), C.Parent must be B's OWN ExecutionId — NOT A. This is
|
||||
// the A→B→C chain proving each level points at its immediate spawner.
|
||||
var client = new Mock<IExternalSystemClient>();
|
||||
client
|
||||
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
|
||||
var writer = new CapturingAuditWriter();
|
||||
|
||||
var library = NewLibrary();
|
||||
Assert.True(library.CompileAndRegister(
|
||||
"C", "await Instance.ExternalSystem.Call(\"ERP\", \"GetOrder\"); return null;"));
|
||||
|
||||
var bExecutionId = Guid.NewGuid();
|
||||
var aExecutionId = Guid.NewGuid(); // B's inherited parent
|
||||
var context = CreateContext(
|
||||
ActorRefs.Nobody, library,
|
||||
externalSystemClient: client.Object,
|
||||
auditWriter: writer,
|
||||
executionId: bExecutionId,
|
||||
parentExecutionId: aExecutionId);
|
||||
|
||||
await context.Scripts.CallShared("C");
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
Assert.Equal(bExecutionId, evt.ParentExecutionId);
|
||||
Assert.NotEqual(aExecutionId, evt.ParentExecutionId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Alarm on-trigger plumbing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void CreateChildContextForSharedScript_ParentIsCallerExecution_FreshOwnId()
|
||||
{
|
||||
// Unit-level proof of the child-context contract the CallShared path uses.
|
||||
var bExecutionId = Guid.NewGuid();
|
||||
var context = CreateContext(
|
||||
ActorRefs.Nobody, NewLibrary(), executionId: bExecutionId);
|
||||
|
||||
var child = context.CreateChildContextForSharedScript(childCallDepth: 1);
|
||||
|
||||
Assert.Equal(bExecutionId, child.ParentExecutionId);
|
||||
Assert.NotEqual(Guid.Empty, child.ExecutionId);
|
||||
Assert.NotEqual(bExecutionId, child.ExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmOnTrigger_NestedCallScript_CarriesAlarmRunsOwnExecutionId_AsParent()
|
||||
{
|
||||
// End-to-end alarm plumbing: when an alarm fires, its on-trigger script
|
||||
// runs in a ScriptRuntimeContext built by AlarmExecutionActor. With no
|
||||
// Guid firing id today the alarm run is a ROOT (its own ParentExecutionId
|
||||
// is null), but it still mints its OWN fresh ExecutionId. A nested
|
||||
// CallScript from that on-trigger script must therefore carry the alarm
|
||||
// run's OWN (non-null) ExecutionId as the child's ParentExecutionId —
|
||||
// proving the alarm context is a proper execution node feeding the
|
||||
// cascade and the parentExecutionId parameter is plumbed end-to-end.
|
||||
var compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
var sharedLibrary = new SharedScriptLibrary(
|
||||
compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
var options = new SiteRuntimeOptions();
|
||||
|
||||
var onTrigger = compilationService.Compile(
|
||||
"OnTrigger", "await Instance.CallScript(\"Child\"); return null;");
|
||||
Assert.NotNull(onTrigger.CompiledScript);
|
||||
|
||||
var alarmConfig = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "HighTemp",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
||||
PriorityLevel = 1
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||||
"HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
|
||||
onTrigger.CompiledScript, sharedLibrary, options,
|
||||
NullLogger<AlarmActor>.Instance)));
|
||||
|
||||
alarm.Tell(new AttributeValueChanged(
|
||||
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||||
|
||||
// The alarm raises (instance gets AlarmStateChanged) AND the on-trigger
|
||||
// script fires its nested CallScript at the instance.
|
||||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
var request = instanceProbe.ExpectMsg<ScriptCallRequest>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal("Child", request.ScriptName);
|
||||
// The alarm run is a root today (its own parent is null), but its OWN
|
||||
// freshly-minted ExecutionId cascades to the child — so the child's
|
||||
// ParentExecutionId is a real, non-empty value, NOT null.
|
||||
Assert.NotNull(request.ParentExecutionId);
|
||||
Assert.NotEqual(Guid.Empty, request.ParentExecutionId!.Value);
|
||||
|
||||
instanceProbe.Reply(new ScriptCallResult(request.CorrelationId, true, null, null));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
@@ -137,3 +142,157 @@ public class ScopeAccessorTests
|
||||
Assert.Equal("[1,2,3]", encoded);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WaitAsync (spec §3-§5, acceptance §7.6) scope-resolution tests. Unlike the
|
||||
/// path-arithmetic tests above, these route a real <see cref="ScriptRuntimeContext"/>
|
||||
/// against a TestProbe standing in for the Instance Actor, so they need a live
|
||||
/// ActorSystem — hence a TestKit-derived class. They assert that
|
||||
/// <c>Attributes.WaitAsync</c> applies <see cref="AttributeAccessor.Resolve"/>
|
||||
/// (the composition prefix) to the key BEFORE the request is sent to the actor —
|
||||
/// the same contract Get/Set obey.
|
||||
/// </summary>
|
||||
public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
|
||||
{
|
||||
private ScriptRuntimeContext MakeContext(IActorRef instanceActor) =>
|
||||
new(
|
||||
instanceActor,
|
||||
instanceActor,
|
||||
sharedScriptLibrary: null!,
|
||||
currentCallDepth: 0,
|
||||
maxCallDepth: 10,
|
||||
askTimeout: TimeSpan.FromSeconds(2),
|
||||
instanceName: "Pump1",
|
||||
logger: NullLogger<ScriptRuntimeContext>.Instance);
|
||||
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
[Fact]
|
||||
public void WaitAsync_Value_AppliesScopeResolution_BeforeSendingRequest()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = MakeContext(probe.Ref);
|
||||
|
||||
// Composed scope "TempSensor" — Resolve("Flag") => "TempSensor.Flag".
|
||||
var acc = new AttributeAccessor(ctx, "TempSensor");
|
||||
|
||||
// Fire-and-forget; the assertion is on the message the actor receives.
|
||||
_ = acc.WaitAsync("Flag", true, TimeSpan.FromSeconds(30));
|
||||
|
||||
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("TempSensor.Flag", req.AttributeName);
|
||||
// The value overload encodes the target via AttributeValueCodec.Encode and
|
||||
// sends a null predicate. bool true encodes to "True" (capital T).
|
||||
Assert.Equal(AttributeValueCodec.Encode(true), req.TargetValueEncoded);
|
||||
Assert.Equal("True", req.TargetValueEncoded);
|
||||
Assert.Null(req.Predicate);
|
||||
Assert.Equal("Pump1", req.InstanceName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitAsync_Predicate_AppliesScopeResolution_AndSendsPredicate()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = MakeContext(probe.Ref);
|
||||
|
||||
var acc = new AttributeAccessor(ctx, "Motor.TempSensor");
|
||||
|
||||
Func<object?, bool> predicate = _ => true;
|
||||
_ = acc.WaitAsync("Level", predicate, TimeSpan.FromSeconds(30));
|
||||
|
||||
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Motor.TempSensor.Level", req.AttributeName);
|
||||
// The predicate overload sends the delegate and a null encoded target.
|
||||
Assert.Null(req.TargetValueEncoded);
|
||||
Assert.NotNull(req.Predicate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitAsync_RootScope_LeavesKeyBare()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = MakeContext(probe.Ref);
|
||||
|
||||
var acc = new AttributeAccessor(ctx, "");
|
||||
_ = acc.WaitAsync("Flag", true, TimeSpan.FromSeconds(30));
|
||||
|
||||
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Flag", req.AttributeName);
|
||||
}
|
||||
|
||||
// ── WaitForAsync (spec §3): scope resolution + populated WaitResult ───────
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAsync_Value_AppliesScopeResolution_AndSurfacesPopulatedWaitResult()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = MakeContext(probe.Ref);
|
||||
|
||||
// Composed scope "TempSensor" — Resolve("Flag") => "TempSensor.Flag".
|
||||
var acc = new AttributeAccessor(ctx, "TempSensor");
|
||||
|
||||
var task = acc.WaitForAsync("Flag", true, TimeSpan.FromSeconds(30));
|
||||
|
||||
// The actor receives the scope-resolved, codec-encoded request.
|
||||
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("TempSensor.Flag", req.AttributeName);
|
||||
Assert.Equal(AttributeValueCodec.Encode(true), req.TargetValueEncoded);
|
||||
Assert.Null(req.Predicate);
|
||||
Assert.False(req.RequireGoodQuality);
|
||||
|
||||
// Reply with a matched response — the accessor must surface the full WaitResult.
|
||||
probe.Reply(new WaitForAttributeResponse(
|
||||
req.CorrelationId, Matched: true, Value: true, Quality: "Good", TimedOut: false));
|
||||
|
||||
var result = await task;
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal(true, result.Value);
|
||||
Assert.Equal("Good", result.Quality);
|
||||
Assert.False(result.TimedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAsync_Predicate_AppliesScopeResolution_AndSurfacesWaitResult()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = MakeContext(probe.Ref);
|
||||
|
||||
var acc = new AttributeAccessor(ctx, "Motor.TempSensor");
|
||||
|
||||
Func<object?, bool> predicate = _ => true;
|
||||
var task = acc.WaitForAsync("Level", predicate, TimeSpan.FromSeconds(30));
|
||||
|
||||
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Motor.TempSensor.Level", req.AttributeName);
|
||||
Assert.Null(req.TargetValueEncoded);
|
||||
Assert.NotNull(req.Predicate);
|
||||
|
||||
probe.Reply(new WaitForAttributeResponse(
|
||||
req.CorrelationId, Matched: true, Value: 42, Quality: "Good", TimedOut: false));
|
||||
|
||||
var result = await task;
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal(42, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForAsync_RequireGoodQuality_ThreadsFlagIntoRequest()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = MakeContext(probe.Ref);
|
||||
|
||||
var acc = new AttributeAccessor(ctx, "");
|
||||
var task = acc.WaitForAsync("Flag", true, TimeSpan.FromSeconds(30), requireGoodQuality: true);
|
||||
|
||||
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(req.RequireGoodQuality);
|
||||
|
||||
probe.Reply(new WaitForAttributeResponse(
|
||||
req.CorrelationId, Matched: false, Value: null, Quality: null, TimedOut: true));
|
||||
|
||||
var result = await task;
|
||||
Assert.False(result.Matched);
|
||||
Assert.True(result.TimedOut);
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user