From 9300bc1a4f069f107422a93139281718e285eda4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 5 Sep 2026 23:59:08 -0400 Subject: [PATCH] feat(audit): GetTraceEventsAsync returns the ScriptTrace chain for Replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flow Debugger (design 2026-09-05 §7.1): IAuditLogRepository.GetTraceEventsAsync walks any entry id to its root and down the Edges/Chain CTE exactly as GetExecutionTreeAsync, then returns the chain's ScriptTrace rows plus the ApiInbound.InboundRequest row via the canonical row mapper, ordered (OccurredAtUtc, EventId). Test doubles implement the new member with a NotSupported stub. Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP --- .../Repositories/IAuditLogRepository.cs | 14 ++ .../Repositories/AuditLogRepository.cs | 126 ++++++++++++++++++ ...ditLogIngestActorCombinedTelemetryTests.cs | 3 + .../Central/AuditLogIngestActorTests.cs | 6 + .../Central/AuditLogPurgeActorTests.cs | 3 + .../Central/CentralAuditWriteFailuresTests.cs | 3 + .../SiteAuditReconciliationActorTests.cs | 12 ++ .../AuditLogRepositoryTraceEventsTests.cs | 76 +++++++++++ .../AuditLog/SiteAuditPushFlowTests.cs | 3 + 9 files changed, 246 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceEventsTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs index 72e31a66..f5d24fde 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs @@ -296,6 +296,20 @@ public interface IAuditLogRepository Guid executionId, CancellationToken ct = default); + /// + /// Flow Debugger Replay (design 2026-09-05 §7.1): every row of the execution tree that + /// belongs to (walked to the root exactly as + /// does) whose channel is ScriptTrace, plus the + /// tree's ApiInbound.InboundRequest rows, ordered (OccurredAtUtc, EventId). + /// Empty when nothing in the store carries the id. + /// + /// Any execution id in the chain. + /// Cancellation token. + /// The canonical audit events that make up the flow, oldest first. + Task> GetTraceEventsAsync( + Guid executionId, + CancellationToken ct = default); + /// /// Returns the distinct, non-null SourceNode values present in the /// AuditLog table, in ascending order. Backs the Audit Log page's diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs index 6bfba4ae..ba29f608 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -9,6 +9,7 @@ using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; @@ -1125,6 +1126,131 @@ VALUES } } + /// + public async Task> GetTraceEventsAsync( + Guid executionId, + CancellationToken ct = default) + { + // Flow Debugger Replay (design 2026-09-05 §7.1): same walk as GetExecutionTreeAsync + // — up to the root, then the Edges/Chain CTE down — but projecting the chain's + // execution ids, then the rows themselves filtered to the trace channel + the + // inbound row. Two statements keep the row read on EF's canonical mapper + // (RowToCanonical) instead of a second hand-mapped reader. + var conn = _context.Database.GetDbConnection(); + var openedHere = false; + if (conn.State != System.Data.ConnectionState.Open) + { + await conn.OpenAsync(ct).ConfigureAwait(false); + openedHere = true; + } + + var chainIds = new List(); + try + { + var rootExecutionId = executionId; + for (var depth = 0; depth < ExecutionChainMaxDepth; depth++) + { + Guid? parent; + await using (var upCmd = conn.CreateCommand()) + { + upCmd.CommandText = + "SELECT TOP 1 ParentExecutionId FROM dbo.AuditLog " + + "WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL;"; + var pCur = upCmd.CreateParameter(); + pCur.ParameterName = "@cur"; + pCur.Value = rootExecutionId; + upCmd.Parameters.Add(pCur); + var result = await upCmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + parent = result is null or DBNull ? null : (Guid)result; + } + if (parent is null) break; + rootExecutionId = parent.Value; + } + + DateTime? rootFirstOccurred; + await using (var winCmd = conn.CreateCommand()) + { + winCmd.CommandText = "SELECT MIN(OccurredAtUtc) FROM dbo.AuditLog WHERE ExecutionId = @root;"; + var pWinRoot = winCmd.CreateParameter(); + pWinRoot.ParameterName = "@root"; + pWinRoot.Value = rootExecutionId; + winCmd.Parameters.Add(pWinRoot); + var winResult = await winCmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + rootFirstOccurred = winResult is null or DBNull ? null : (DateTime)winResult; + } + var edgesWindowPredicate = rootFirstOccurred is null + ? string.Empty + : "AND OccurredAtUtc >= @winStart AND OccurredAtUtc < @winEnd"; + + await using (var idsCmd = conn.CreateCommand()) + { + idsCmd.CommandText = $@" + WITH Edges AS ( + SELECT DISTINCT ExecutionId, ParentExecutionId + FROM dbo.AuditLog + WHERE ExecutionId IS NOT NULL + {edgesWindowPredicate} + ), + Chain AS ( + SELECT CAST(@root AS uniqueidentifier) AS ExecutionId, + CAST(NULL AS uniqueidentifier) AS ParentExecutionId + UNION ALL + SELECT e.ExecutionId, e.ParentExecutionId + FROM Edges e + INNER JOIN Chain c ON e.ParentExecutionId = c.ExecutionId + ) + SELECT ExecutionId FROM Chain + UNION + SELECT ParentExecutionId FROM Chain WHERE ParentExecutionId IS NOT NULL + OPTION (MAXRECURSION {ExecutionChainMaxDepth});"; + var pRoot = idsCmd.CreateParameter(); + pRoot.ParameterName = "@root"; + pRoot.Value = rootExecutionId; + idsCmd.Parameters.Add(pRoot); + if (rootFirstOccurred is { } rootFirst) + { + var pWinStart = idsCmd.CreateParameter(); + pWinStart.ParameterName = "@winStart"; + pWinStart.Value = rootFirst - ExecutionTreeWindowSlack; + idsCmd.Parameters.Add(pWinStart); + var pWinEnd = idsCmd.CreateParameter(); + pWinEnd.ParameterName = "@winEnd"; + pWinEnd.Value = rootFirst + ExecutionTreeMaxSpan; + idsCmd.Parameters.Add(pWinEnd); + } + await using var reader = await idsCmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + if (!reader.IsDBNull(0)) chainIds.Add(reader.GetGuid(0)); + } + } + } + finally + { + if (openedHere) + { + await conn.CloseAsync().ConfigureAwait(false); + } + } + + if (chainIds.Count == 0) + { + return Array.Empty(); + } + + var rows = await _context.Set() + .AsNoTracking() + .Where(r => r.ExecutionId != null && chainIds.Contains(r.ExecutionId.Value)) + .Where(r => r.Channel == AuditChannel.ScriptTrace + || (r.Channel == AuditChannel.ApiInbound && r.Kind == AuditKind.InboundRequest)) + .OrderBy(r => r.OccurredAtUtc) + .ThenBy(r => r.EventId) + .ToListAsync(ct) + .ConfigureAwait(false); + + return rows.Select(RowToCanonical).ToList(); + } + /// public async Task> GetDistinctSourceNodesAsync(CancellationToken ct = default) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs index 45c2248e..fd01ce04 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs @@ -430,6 +430,9 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< /// private sealed class BatchFailingAuditRepo : IAuditLogRepository { + public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default) + => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + private readonly IAuditLogRepository _inner; public BatchFailingAuditRepo(IAuditLogRepository inner) { _inner = inner; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs index aa6f70e5..2703d0db 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs @@ -382,6 +382,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture private sealed class TokenRecordingRepository : IAuditLogRepository { + public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default) + => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + public CancellationToken? BatchToken { get; private set; } public List RowTokens { get; } = new(); @@ -438,6 +441,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture private sealed class ThrowingRepository : IAuditLogRepository { + public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default) + => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + private readonly IAuditLogRepository _inner; private readonly Guid _poisonId; diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs index 457cba2f..98cc73cd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs @@ -45,6 +45,9 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture private sealed class RecordingRepo : IAuditLogRepository { + public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default) + => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + public List ThresholdQueries { get; } = new(); public List SwitchedBoundaries { get; } = new(); public Func RowsPerBoundary { get; set; } = _ => 0L; diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs index 40e8832a..e377fe8c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs @@ -37,6 +37,9 @@ public class CentralAuditWriteFailuresTests : TestKit /// private sealed class ThrowingRepo : IAuditLogRepository { + public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default) + => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) => throw new InvalidOperationException("simulated repo failure"); public Task> QueryAsync( diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs index 54634777..6d21925b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs @@ -68,6 +68,9 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture private sealed class RecordingRepo : IAuditLogRepository { + public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default) + => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + public List Inserted { get; } = new(); private readonly HashSet _seen = new(); public int InsertCallCount { get; private set; } @@ -121,6 +124,9 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture private sealed class PoisonInsertRepo : IAuditLogRepository { + public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default) + => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + private readonly Guid _poisonId; private readonly Exception _fault; public List Inserted { get; } = new(); @@ -192,6 +198,9 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture private sealed class AlwaysFailUnlessSyntheticRepo : IAuditLogRepository { + public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default) + => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + public List Inserted { get; } = new(); private readonly HashSet _seen = new(); @@ -603,6 +612,9 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixtureRepository whose every insert throws, so the retry hold-back path is taken. private sealed class AlwaysThrowingRepo : IAuditLogRepository { + public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default) + => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) => throw new InvalidOperationException("central insert failed"); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceEventsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceEventsTests.cs new file mode 100644 index 00000000..4568e4e0 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceEventsTests.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; +using Xunit; + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories; + +/// +/// Flow Debugger Replay (design 2026-09-05 §7.1): +/// returns the whole chain's ScriptTrace rows plus the inbound row, from ANY entry id, ordered, +/// and excludes unrelated and non-trace rows. Runs against the shared MSSQL fixture. +/// +public class AuditLogRepositoryTraceEventsTests : IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + public AuditLogRepositoryTraceEventsTests(MsSqlMigrationFixture fixture) => _fixture = fixture; + + private ScadaBridgeDbContext CreateContext() => new( + new DbContextOptionsBuilder().UseSqlServer(_fixture.ConnectionString).Options); + + [SkippableFact] + public async Task ReturnsInboundPlusChainTraceRows_Ordered_FromAnyEntry() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + var siteId = "test-trace-" + Guid.NewGuid().ToString("N")[..8]; + var t0 = new DateTime(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc); + var root = Guid.NewGuid(); + var siteRun = Guid.NewGuid(); + var cascade = Guid.NewGuid(); + + var inbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, + occurredAtUtc: t0, actor: "key", target: "M", executionId: root, sourceSiteId: siteId, responseSummary: "{}"); + var route = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(10), target: "I.S", executionId: root, sourceSiteId: siteId, + extra: new ScriptTraceExtra { ChildExecutionId = siteRun }.ToJson()); + var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(20), target: "S", executionId: siteRun, parentExecutionId: root, sourceSiteId: siteId); + var write = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(30), target: "A", executionId: siteRun, parentExecutionId: root, sourceSiteId: siteId); + var cascadeRun = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(40), target: "C", executionId: cascade, parentExecutionId: siteRun, sourceSiteId: siteId); + // Same chain but NOT a trace row: an outbound API call the site script made — excluded. + var outbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(25), target: "ERP.x", executionId: siteRun, parentExecutionId: root, sourceSiteId: siteId); + // Unrelated chain. + var other = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, + occurredAtUtc: t0.AddMilliseconds(5), target: "Z", executionId: Guid.NewGuid(), sourceSiteId: siteId); + + await using (var ctx = CreateContext()) + { + var repo = new AuditLogRepository(ctx); + foreach (var e in new[] { cascadeRun, other, write, outbound, inbound, run, route }) + await repo.InsertIfNotExistsAsync(e); + } + + await using (var ctx = CreateContext()) + { + var repo = new AuditLogRepository(ctx); + foreach (var entry in new[] { root, siteRun, cascade }) + { + var events = await repo.GetTraceEventsAsync(entry); + Assert.Equal( + new[] { inbound.EventId, route.EventId, run.EventId, write.EventId, cascadeRun.EventId }, + events.Select(e => e.EventId).ToArray()); + Assert.Equal(inbound, events[0]); + Assert.Equal(cascadeRun, events[4]); + } + + Assert.Empty(await repo.GetTraceEventsAsync(Guid.NewGuid())); + } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs index 8b9cc57b..7096ff65 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs @@ -89,6 +89,9 @@ public class SiteAuditPushFlowTests : TestKit /// private sealed class InMemoryAuditLogRepository : IAuditLogRepository { + public Task> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default) + => throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double."); + private readonly ConcurrentDictionary _rows = new(); public IReadOnlyCollection Rows => _rows.Values.ToList();