merge: integrate WaitAsync/M5-audit (parallel session) with galaxy array-write + inbound-timeout fixes

This commit is contained in:
Joseph Doherty
2026-06-17 09:28:15 -04:00
88 changed files with 7714 additions and 169 deletions
@@ -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);
}
}
@@ -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>());
@@ -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);
}
}
@@ -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 &lt; 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>())));
}
}
}
@@ -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.");
}
}
@@ -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));
}
}
@@ -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}");
}
}
@@ -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>());
}
}
}
@@ -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]
@@ -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);
}
}