From 255dd95cd9e100d7f74e37416435591c772fc241 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 18:21:49 -0400 Subject: [PATCH] feat(auditlog): GetExecutionTreeAsync recursive execution-chain query --- .../Repositories/IAuditLogRepository.cs | 41 ++++ .../Types/Audit/ExecutionTreeNode.cs | 71 +++++++ .../Repositories/AuditLogRepository.cs | 193 ++++++++++++++++++ .../Central/AuditLogIngestActorTests.cs | 4 + .../Central/AuditLogPurgeActorTests.cs | 4 + .../Central/CentralAuditWriteFailuresTests.cs | 4 + .../SiteAuditReconciliationActorTests.cs | 4 + .../Repositories/AuditLogRepositoryTests.cs | 137 +++++++++++++ .../AuditLog/SiteAuditPushFlowTests.cs | 4 + 9 files changed, 462 insertions(+) create mode 100644 src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs index 36b0d0f..13fd96b 100644 --- a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs +++ b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs @@ -134,4 +134,45 @@ public interface IAuditLogRepository TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default); + + /// + /// Audit Log ParentExecutionId feature (Task 8) — given any + /// in an execution chain, returns the whole + /// chain rooted at the topmost ancestor: one + /// per distinct execution, summarising its AuditLog rows. The Central + /// UI renders the result as a tree. + /// + /// + /// + /// The input id may be any node in the chain — a leaf, the root, or a middle + /// node. The implementation first walks up via + /// ParentExecutionId to find the root, then walks down from + /// the root via a recursive CTE, so the full chain is returned regardless of + /// entry point. + /// + /// + /// The ParentExecutionId graph is a tree (acyclic by construction — + /// each execution is minted fresh and its parent always pre-exists). Both + /// the upward walk and the downward CTE are nonetheless bounded at 32 levels + /// as a guard against corrupt/pathological data: a depth that exceeds the + /// guard raises an error rather than hanging the server. Chains are shallow + /// (1-2 levels typical) so the guard is never reached in practice. + /// + /// + /// A "stub" node — an execution that emitted no rows of its own yet is + /// referenced by a child via ParentExecutionId, or whose rows have + /// been purged — still appears, with + /// = 0. A purged/missing parent simply ends the upward walk. + /// + /// + /// When no AuditLog row carries in + /// either ExecutionId or ParentExecutionId, the result is a + /// single stub node for itself + /// ( = 0) — consistent with the + /// stub-node treatment of any other row-less execution. + /// + /// + Task> GetExecutionTreeAsync( + Guid executionId, + CancellationToken ct = default); } diff --git a/src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs b/src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs new file mode 100644 index 0000000..efce45f --- /dev/null +++ b/src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs @@ -0,0 +1,71 @@ +namespace ScadaLink.Commons.Types.Audit; + +/// +/// One execution within an execution chain returned by +/// . +/// Each node summarises the AuditLog rows sharing a single +/// ; the Central UI renders the set as a tree by +/// joining to a parent node's +/// . +/// +/// +/// +/// Stub nodes. An execution that performed a trust-boundary action but +/// crossed it without emitting any audit row — or whose own rows have been +/// purged — still appears as a node when a child references it via +/// . Such a stub node has +/// = 0, empty /, null +/// /, null timestamps, +/// and a null (a purged/ghost parent leaves no +/// row from which its own parent could be read — the upward walk ends there). +/// +/// +/// and are the distinct sets of +/// the corresponding enum names present across the execution's rows, modelled +/// as of string to mirror how the repository's +/// query filters already pass small bounded sets around. +/// +/// +/// The execution this node summarises. +/// +/// The of the spawning execution, or null for the +/// root (and for stub nodes, whose own parent is unknowable). +/// +/// +/// Number of AuditLog rows carrying this ; 0 for +/// a stub node. +/// +/// +/// Distinct names +/// present across this execution's rows; empty for a stub node. +/// +/// +/// Distinct names +/// present across this execution's rows; empty for a stub node. +/// +/// +/// Source site of the execution's rows when consistent; null for a stub node +/// (or when the rows carry no site). +/// +/// +/// Source instance of the execution's rows when consistent; null for a stub +/// node (or when the rows carry no instance). +/// +/// +/// Earliest OccurredAtUtc across this execution's rows; null for a stub +/// node. +/// +/// +/// Latest OccurredAtUtc across this execution's rows; null for a stub +/// node. +/// +public sealed record ExecutionTreeNode( + Guid ExecutionId, + Guid? ParentExecutionId, + int RowCount, + IReadOnlyList Channels, + IReadOnlyList Statuses, + string? SourceSiteId, + string? SourceInstanceId, + DateTime? FirstOccurredAtUtc, + DateTime? LastOccurredAtUtc); diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs index 2c5cabf..85afb46 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -555,4 +555,197 @@ VALUES BacklogTotal: 0L, AsOfUtc: anchorUtc); } + + // Hard ceiling on chain depth for both the upward walk and the downward + // recursive CTE. The ParentExecutionId graph is a tree (acyclic by + // construction — each execution is minted fresh, its parent always + // pre-exists), so this is purely a guard against corrupt/pathological data: + // a cycle must surface as a bounded error rather than hang the server. + // Chains are shallow (1-2 levels typical) so the guard is never reached in + // practice. + private const int ExecutionChainMaxDepth = 32; + + /// + /// Audit Log ParentExecutionId (Task 8) — returns the whole execution chain + /// containing , regardless of entry point. + /// + /// + /// + /// Two phases. Walk up: an iterative + /// SELECT TOP 1 ParentExecutionId … WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL + /// climbs from the supplied node to the root — the last execution id with no + /// parent. The loop is capped at + /// iterations; a purged/missing parent simply ends the climb early. Walk + /// down: a recursive CTE seeded at the root joins + /// child.ParentExecutionId = parent.ExecutionId to enumerate every + /// descendant, bounded by OPTION (MAXRECURSION 32) — corrupt cyclic + /// data raises a (msg 530) rather than spinning. + /// + /// + /// The chain's full execution-id set is the union of the rows' + /// ExecutionId and their ParentExecutionId, so an execution + /// referenced only as a parent — a "stub" that emitted no rows of its own — + /// is included. The final projection LEFT JOINs that id set back to + /// AuditLog and GROUP BYs, so a stub yields a node with + /// RowCount = 0 and empty/null aggregates. The query is SELECT-only + /// (the audit writer role grants no UPDATE/DELETE — reads are unrestricted). + /// + /// + public async Task> GetExecutionTreeAsync( + Guid executionId, + CancellationToken ct = default) + { + var conn = _context.Database.GetDbConnection(); + var openedHere = false; + if (conn.State != System.Data.ConnectionState.Open) + { + await conn.OpenAsync(ct).ConfigureAwait(false); + openedHere = true; + } + + try + { + // --- Phase 1: walk up to the root --------------------------------- + // Climb ParentExecutionId until a node has no parent (root) or the + // parent execution has no rows of its own (purged/stub — the climb + // cannot continue past a row-less node). The depth cap guards + // against a cycle in corrupt data; a tree never reaches it. + 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) + { + // No parent row for the current node — it is the root (or a + // row-less stub at the top of what survives). Stop climbing. + break; + } + + rootExecutionId = parent.Value; + } + + // --- Phase 2: walk down from the root via a recursive CTE --------- + // Chain : seeded at the root, recursively pulls every distinct + // ExecutionId whose rows carry a ParentExecutionId already + // in the chain. SELECT DISTINCT in the recursive member is + // rejected by SQL Server, so the recursion walks raw rows + // and the outer query de-duplicates. + // ChainIds: the chain's full execution-id set = every ExecutionId in + // Chain UNIONed with every non-null ParentExecutionId — the + // UNION pulls in stub parents that emitted no rows. + // Final : LEFT JOIN ChainIds back to AuditLog and GROUP BY so a + // stub surfaces with RowCount 0 and NULL aggregates. + var nodes = new List(); + await using (var downCmd = conn.CreateCommand()) + { + downCmd.CommandText = @" + WITH Chain AS ( + SELECT CAST(@root AS uniqueidentifier) AS ExecutionId + UNION ALL + SELECT a.ExecutionId + FROM dbo.AuditLog a + INNER JOIN Chain c ON a.ParentExecutionId = c.ExecutionId + WHERE a.ExecutionId IS NOT NULL + ), + ChainIds AS ( + SELECT DISTINCT ExecutionId FROM Chain + UNION + SELECT DISTINCT a.ParentExecutionId + FROM dbo.AuditLog a + INNER JOIN Chain c ON a.ExecutionId = c.ExecutionId + WHERE a.ParentExecutionId IS NOT NULL + ) + SELECT + ids.ExecutionId AS [ExecutionId], + MIN(a.ParentExecutionId) AS [ParentExecutionId], + COUNT(a.EventId) AS [RowCount], + (SELECT STRING_AGG(d.Channel, ',') + FROM (SELECT DISTINCT a2.Channel FROM dbo.AuditLog a2 + WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Channels], + (SELECT STRING_AGG(d.Status, ',') + FROM (SELECT DISTINCT a2.Status FROM dbo.AuditLog a2 + WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Statuses], + MIN(a.SourceSiteId) AS [SourceSiteId], + MIN(a.SourceInstanceId) AS [SourceInstanceId], + MIN(a.OccurredAtUtc) AS [FirstOccurredAtUtc], + MAX(a.OccurredAtUtc) AS [LastOccurredAtUtc] + FROM ChainIds ids + LEFT JOIN dbo.AuditLog a ON a.ExecutionId = ids.ExecutionId + GROUP BY ids.ExecutionId + OPTION (MAXRECURSION 32);"; + + var pRoot = downCmd.CreateParameter(); + pRoot.ParameterName = "@root"; + pRoot.Value = rootExecutionId; + downCmd.Parameters.Add(pRoot); + + await using var reader = await downCmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + var nodeExecutionId = reader.GetGuid(0); + Guid? parentExecutionId = reader.IsDBNull(1) ? null : reader.GetGuid(1); + var rowCount = reader.GetInt32(2); + var channels = SplitAggregate(reader.IsDBNull(3) ? null : reader.GetString(3)); + var statuses = SplitAggregate(reader.IsDBNull(4) ? null : reader.GetString(4)); + var sourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5); + var sourceInstanceId = reader.IsDBNull(6) ? null : reader.GetString(6); + DateTime? firstOccurred = reader.IsDBNull(7) ? null : reader.GetDateTime(7); + DateTime? lastOccurred = reader.IsDBNull(8) ? null : reader.GetDateTime(8); + + nodes.Add(new ExecutionTreeNode( + ExecutionId: nodeExecutionId, + ParentExecutionId: parentExecutionId, + RowCount: rowCount, + Channels: channels, + Statuses: statuses, + SourceSiteId: sourceSiteId, + SourceInstanceId: sourceInstanceId, + FirstOccurredAtUtc: firstOccurred, + LastOccurredAtUtc: lastOccurred)); + } + } + + return nodes; + } + finally + { + if (openedHere) + { + await conn.CloseAsync().ConfigureAwait(false); + } + } + } + + /// + /// Splits a STRING_AGG comma-joined value into a distinct, ordered + /// list. A null/empty aggregate (a stub node with no rows) yields an empty + /// list rather than a single empty string. + /// + private static IReadOnlyList SplitAggregate(string? aggregate) + { + if (string.IsNullOrEmpty(aggregate)) + { + return Array.Empty(); + } + + return aggregate + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.Ordinal) + .OrderBy(v => v, StringComparer.Ordinal) + .ToArray(); + } } diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs index 51a0bb7..32a6606 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs @@ -224,5 +224,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => _inner.GetKpiSnapshotAsync(window, nowUtc, ct); + + public Task> GetExecutionTreeAsync( + Guid executionId, CancellationToken ct = default) => + _inner.GetExecutionTreeAsync(executionId, ct); } } diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs index 241b720..290ff16 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs @@ -82,6 +82,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); + + public Task> GetExecutionTreeAsync( + Guid executionId, CancellationToken ct = default) => + Task.FromResult>(Array.Empty()); } private IServiceProvider BuildScopedProvider(IAuditLogRepository repo) diff --git a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs index b4d3569..4ad28a8 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs @@ -51,6 +51,10 @@ public class CentralAuditWriteFailuresTests : TestKit public Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); + + public Task> GetExecutionTreeAsync( + Guid executionId, CancellationToken ct = default) => + Task.FromResult>(Array.Empty()); } /// diff --git a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs index 87b5024..f8b3b49 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs @@ -97,6 +97,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); + + public Task> GetExecutionTreeAsync( + Guid executionId, CancellationToken ct = default) => + Task.FromResult>(Array.Empty()); } /// diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs index 01ab04d..f4ddc4a 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs @@ -746,6 +746,143 @@ public class AuditLogRepositoryTests : IClassFixture Assert.Equal(nowUtc, snapshot.AsOfUtc); } + // ------------------------------------------------------------------------ + // Audit Log ParentExecutionId (Task 8): GetExecutionTreeAsync + // ------------------------------------------------------------------------ + // + // GetExecutionTreeAsync walks UP from any node to the chain root, then walks + // DOWN via a recursive CTE, returning one ExecutionTreeNode per distinct + // execution in the chain. These tests verify the observable behaviour: + // * a multi-level chain returns the full set regardless of entry node + // * a parent referenced only via ParentExecutionId (no rows of its own) + // still surfaces, as a RowCount = 0 stub node + // * pathological cyclic data is bounded by the MAXRECURSION guard and + // surfaces a SqlException rather than hanging + + [SkippableFact] + public async Task GetExecutionTree_MultiLevelChain() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // A 3-level chain: root -> mid -> leaf. Each execution emits two rows so + // RowCount aggregation is exercised; the child rows carry the parent's + // ExecutionId as ParentExecutionId. + var rootExec = Guid.NewGuid(); + var midExec = Guid.NewGuid(); + var leafExec = Guid.NewGuid(); + + var t0 = new DateTime(2026, 10, 5, 9, 0, 0, DateTimeKind.Utc); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: rootExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: rootExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), executionId: midExec, parentExecutionId: rootExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), executionId: midExec, parentExecutionId: rootExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(4), executionId: leafExec, parentExecutionId: midExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(5), executionId: leafExec, parentExecutionId: midExec)); + + var expected = new[] { rootExec, midExec, leafExec }; + + // Entry point must not matter: leaf, middle node, and root all yield the + // same full chain. + foreach (var entry in new[] { leafExec, midExec, rootExec }) + { + var tree = await repo.GetExecutionTreeAsync(entry); + + Assert.Equal(3, tree.Count); + Assert.True( + expected.ToHashSet().SetEquals(tree.Select(n => n.ExecutionId)), + $"entry {entry} did not return the full chain"); + + var root = tree.Single(n => n.ExecutionId == rootExec); + var mid = tree.Single(n => n.ExecutionId == midExec); + var leaf = tree.Single(n => n.ExecutionId == leafExec); + + Assert.Null(root.ParentExecutionId); + Assert.Equal(rootExec, mid.ParentExecutionId); + Assert.Equal(midExec, leaf.ParentExecutionId); + + Assert.Equal(2, root.RowCount); + Assert.Equal(2, mid.RowCount); + Assert.Equal(2, leaf.RowCount); + } + } + + [SkippableFact] + public async Task GetExecutionTree_StubParentNode() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // The parent execution emitted no rows of its own (it performed no + // trust-boundary action, or its rows were purged). Only the child has + // rows, and they reference the parent via ParentExecutionId. The parent + // must still surface as a node — a RowCount = 0 stub. + var stubParentExec = Guid.NewGuid(); + var childExec = Guid.NewGuid(); + + var t0 = new DateTime(2026, 10, 6, 9, 0, 0, DateTimeKind.Utc); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: childExec, parentExecutionId: stubParentExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: childExec, parentExecutionId: stubParentExec)); + + // Entering by the child must surface BOTH the child and the stub parent. + var tree = await repo.GetExecutionTreeAsync(childExec); + + Assert.Equal(2, tree.Count); + + var stub = tree.Single(n => n.ExecutionId == stubParentExec); + var child = tree.Single(n => n.ExecutionId == childExec); + + // The stub node: no rows, empty aggregate sets, null parent and timestamps. + Assert.Equal(0, stub.RowCount); + Assert.Empty(stub.Channels); + Assert.Empty(stub.Statuses); + Assert.Null(stub.ParentExecutionId); + Assert.Null(stub.FirstOccurredAtUtc); + Assert.Null(stub.LastOccurredAtUtc); + + // The child node carries its rows and points at the stub parent. + Assert.Equal(2, child.RowCount); + Assert.Equal(stubParentExec, child.ParentExecutionId); + } + + [SkippableFact] + public async Task GetExecutionTree_RespectsMaxRecursion() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // Pathological cyclic data: A's rows point at B as parent, B's rows + // point back at A. The ParentExecutionId graph is acyclic by + // construction in production, but corrupt data must not hang the + // server. The downward recursive CTE's OPTION (MAXRECURSION 32) raises + // a SqlException when the cycle exceeds the guard; the method surfaces + // it rather than spinning forever. + var execA = Guid.NewGuid(); + var execB = Guid.NewGuid(); + + var t0 = new DateTime(2026, 10, 7, 9, 0, 0, DateTimeKind.Utc); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: execA, parentExecutionId: execB)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: execB, parentExecutionId: execA)); + + // The call must complete (throw) quickly, not hang. A generous 30s + // ceiling distinguishes "bounded failure" from "infinite loop". + var call = repo.GetExecutionTreeAsync(execA); + var completed = await Task.WhenAny(call, Task.Delay(TimeSpan.FromSeconds(30))); + Assert.Same(call, completed); + + // MAXRECURSION exceeded surfaces as a SqlException — bounded, not a hang. + await Assert.ThrowsAsync(() => call); + } + private async Task ScalarAsync(ScadaLinkDbContext context, string sql) { var conn = context.Database.GetDbConnection(); diff --git a/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs b/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs index 05b2693..be01f04 100644 --- a/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs +++ b/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs @@ -96,6 +96,10 @@ public class SiteAuditPushFlowTests : TestKit public Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => throw new NotSupportedException(); + + public Task> GetExecutionTreeAsync( + Guid executionId, CancellationToken ct = default) + => throw new NotSupportedException(); } private static AuditEvent NewPendingEvent(Guid id) => new()