diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor index 1c3ea16..15cac07 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor @@ -173,6 +173,14 @@ View parent execution } + @if (Event.ExecutionId is not null) + { + + } + } + else + { + + } + +
+
+ + @ShortId(node.ExecutionId) + + @if (isCurrent) + { + Arrived from + } + @if (isStub) + { + No audited actions + } + else + { + + @node.RowCount audit @(node.RowCount == 1 ? "row" : "rows") + + } +
+ + @if (isStub) + { +
+ Execution with no audited actions — referenced as a parent, but it + emitted no audit rows of its own (or its rows have been purged). +
+ } + else + { +
+ + Source + @(node.SourceSiteId ?? "—")@(node.SourceInstanceId is null ? "" : " / " + node.SourceInstanceId) + + @if (node.Channels.Count > 0) + { + + Channels + @string.Join(", ", node.Channels) + + } + @if (node.Statuses.Count > 0) + { + + Statuses + @string.Join(", ", node.Statuses) + + } + + Time span + @FormatSpan(node.FirstOccurredAtUtc, node.LastOccurredAtUtc) + +
+ } +
+ + + @if (subtree.Children.Count > 0 && IsExpanded(node.ExecutionId)) + { + @* Recurse: each child subtree is already assembled, so the + nested instance renders directly from PreBuiltRoots and skips + the flat-list assembly entirely. *@ + + } + + } + diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs new file mode 100644 index 0000000..a5a5aef --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs @@ -0,0 +1,241 @@ +using System.Globalization; +using Microsoft.AspNetCore.Components; +using ScadaLink.Commons.Types.Audit; + +namespace ScadaLink.CentralUI.Components.Audit; + +/// +/// Recursive Blazor tree component for the execution-chain view (Audit Log +/// ParentExecutionId feature, Task 10). +/// +/// +/// Flat list → tree. The repository / query service returns the chain as +/// a FLAT list (one per distinct execution). The +/// root instance ( == 0) assembles it once in +/// : it groups by , +/// links each node to its parent via , +/// and identifies the roots (nodes whose parent is null or not present in the +/// list — a purged/ghost parent). Nested instances skip assembly: the parent +/// hands each child subtree down pre-built via . +/// +/// +/// +/// Cycle safety. The ParentExecutionId graph is acyclic by +/// construction, but the UI must not infinite-loop on corrupt data. Assembly +/// tracks visited values while +/// walking children, so a node is attached to the tree at most once — a cycle +/// (A→B, B→A) is broken at the first revisit and every execution still renders +/// exactly once. +/// +/// +/// +/// Presentation. Each node shows the short execution id (a link to +/// /audit/log?executionId={id}), row count, channels/statuses, source +/// site/instance, and time span. A stub node ( +/// == 0) is marked "No audited actions". The node the user arrived from +/// () is highlighted. Nodes with children +/// are expandable; all nodes start expanded so the whole chain is visible. +/// +/// +public partial class ExecutionTree +{ + /// + /// One assembled subtree: a node plus its already-linked child subtrees. + /// Recursive — children are themselves values. + /// + /// The execution this subtree is rooted at. + /// Child subtrees, ordered by first-occurrence time. + public sealed record Subtree(ExecutionTreeNode Node, IReadOnlyList Children); + + /// + /// The flat node list to assemble into a tree. Supplied on the ROOT + /// instance only ( == 0); nested instances receive + /// instead. + /// + [Parameter] public IReadOnlyList? Nodes { get; set; } + + /// + /// Pre-assembled child subtrees, threaded down from a parent + /// so nested instances render without + /// re-running the flat-list assembly. Null / unused on the root instance. + /// + [Parameter] public IReadOnlyList? PreBuiltRoots { get; set; } + + /// + /// The execution the user drilled in from — its node is visually + /// highlighted so the user keeps their bearings within the chain. + /// + [Parameter] public Guid ArrivedFromExecutionId { get; set; } + + /// + /// Nesting depth. 0 on the root instance (which owns flat-list assembly); + /// each recursive child increments it. Used purely to pick the assembly + /// path and to tag the root <ul> for styling. + /// + [Parameter] public int Depth { get; set; } + + // The subtrees this instance renders: assembled from Nodes on the root, + // or taken straight from PreBuiltRoots on a nested instance. + private IReadOnlyList _rootsToRender = Array.Empty(); + + // Per-execution expand/collapse state. Absent => expanded (the default): + // the whole chain is shown on arrival so the user sees the full picture. + private readonly HashSet _collapsed = new(); + + protected override void OnParametersSet() + { + // Nested instance: the parent already assembled our subtrees. + if (Depth > 0) + { + _rootsToRender = PreBuiltRoots ?? Array.Empty(); + return; + } + + // Root instance: assemble the flat list into a tree. + _rootsToRender = BuildForest(Nodes ?? Array.Empty()); + } + + /// + /// Assembles the flat list into a forest of + /// values. There is normally exactly one root (the + /// chain's topmost ancestor); the method returns a list to stay total if + /// the input ever contains disjoint fragments. + /// + private static IReadOnlyList BuildForest(IReadOnlyList nodes) + { + if (nodes.Count == 0) + { + return Array.Empty(); + } + + // De-dupe defensively: the repository emits one node per execution, but + // a corrupt feed could repeat an id. First write wins. + var byId = new Dictionary(); + foreach (var node in nodes) + { + byId.TryAdd(node.ExecutionId, node); + } + + // Children grouped by parent id. A node whose parent is null or absent + // from the list (a purged/ghost parent) is a root. + var childrenByParent = new Dictionary>(); + var roots = new List(); + foreach (var node in byId.Values) + { + if (node.ParentExecutionId is { } parentId && byId.ContainsKey(parentId)) + { + if (!childrenByParent.TryGetValue(parentId, out var bucket)) + { + bucket = new List(); + childrenByParent[parentId] = bucket; + } + bucket.Add(node); + } + else + { + roots.Add(node); + } + } + + // Cycle guard: if the input is fully cyclic (A→B, B→A) every node has a + // present parent, so `roots` is empty even though there is data to + // show. Fall back to treating the lowest-ordered id as the root so the + // chain still renders — the visited-set below then breaks the cycle. + if (roots.Count == 0) + { + roots.Add(byId.Values.OrderBy(n => n.ExecutionId).First()); + } + + var visited = new HashSet(); + return roots + .OrderBy(SortKey) + .Select(root => BuildSubtree(root, childrenByParent, visited)) + .ToList(); + } + + /// + /// Recursively builds one , tracking + /// so a cyclic flat list cannot drive unbounded + /// recursion — a node already attached is never descended into again. + /// + private static Subtree BuildSubtree( + ExecutionTreeNode node, + IReadOnlyDictionary> childrenByParent, + HashSet visited) + { + visited.Add(node.ExecutionId); + + var children = new List(); + if (childrenByParent.TryGetValue(node.ExecutionId, out var directChildren)) + { + foreach (var child in directChildren.OrderBy(SortKey)) + { + // Cycle / DAG guard: skip any execution already placed in the + // tree so each renders exactly once and recursion terminates. + if (visited.Contains(child.ExecutionId)) + { + continue; + } + children.Add(BuildSubtree(child, childrenByParent, visited)); + } + } + + return new Subtree(node, children); + } + + // Stable child ordering: earliest activity first; stub nodes (null + // timestamp) sort last; ExecutionId breaks ties so rendering is + // deterministic across requests. + private static (DateTime, Guid) SortKey(ExecutionTreeNode node) + => (node.FirstOccurredAtUtc ?? DateTime.MaxValue, node.ExecutionId); + + private bool IsExpanded(Guid executionId) => !_collapsed.Contains(executionId); + + private void ToggleExpand(Guid executionId) + { + if (!_collapsed.Remove(executionId)) + { + _collapsed.Add(executionId); + } + } + + /// Audit Log deep link filtered to one execution's rows. + private static string AuditLogUrl(Guid executionId) + => $"/audit/log?executionId={executionId}"; + + /// First 8 hex digits — the short-id presentation used across the Audit UI. + private static string ShortId(Guid value) + { + var n = value.ToString("N"); + return n.Length >= 8 ? n[..8] : n; + } + + /// + /// Renders the [first, last] occurrence span. Both null on a stub node + /// (handled by the caller); a single-row execution shows one timestamp. + /// + private static string FormatSpan(DateTime? firstUtc, DateTime? lastUtc) + { + if (firstUtc is null && lastUtc is null) + { + return "—"; + } + + var first = firstUtc ?? lastUtc!.Value; + var last = lastUtc ?? firstUtc!.Value; + var firstText = Iso(first); + if (first == last) + { + return firstText; + } + return $"{firstText} → {Iso(last)}"; + } + + private static string Iso(DateTime utc) + { + var kind = utc.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(utc, DateTimeKind.Utc) + : utc; + return kind.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture); + } +} diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css new file mode 100644 index 0000000..8f483a7 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css @@ -0,0 +1,137 @@ +/* Execution-chain tree (Audit Log ParentExecutionId feature, Task 10). + Clean, corporate, internal-tool aesthetic — consistent with the Audit Log + grid / drilldown drawer. Bootstrap CSS variables drive every colour so the + tree tracks the active theme. No component framework, no JS for layout. */ + +.execution-tree { + list-style: none; + margin: 0; + padding: 0; +} + +/* Nested lists indent and carry a vertical guide rule that ties children to + their parent — the classic file-tree connector, kept subtle. */ +.execution-tree--root { + padding-left: 0; +} + +.execution-tree .execution-tree { + margin-left: 0.75rem; + padding-left: 1rem; + border-left: 1px solid var(--bs-border-color); +} + +.execution-tree-item { + position: relative; +} + +/* The node card: a flex row of [toggle][body]. */ +.execution-tree-node { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + margin: 0.25rem 0; + border: 1px solid var(--bs-border-color); + border-radius: 0.375rem; + background-color: var(--bs-body-bg); +} + +/* The execution the user drilled in from — a left accent rule + tinted + background so it stands out without shouting. */ +.execution-tree-node--current { + border-color: var(--bs-primary-border-subtle); + background-color: var(--bs-primary-bg-subtle); + box-shadow: inset 3px 0 0 0 var(--bs-primary); +} + +/* Stub node — an execution with no audited actions. Muted + dashed border so + it reads as a placeholder rather than a real audited execution. */ +.execution-tree-node--stub { + border-style: dashed; + background-color: var(--bs-tertiary-bg); +} + +/* Expand / collapse control. A small square that mirrors the table-light + header tone used elsewhere on the Audit pages. */ +.execution-tree-toggle { + flex: 0 0 auto; + width: 1.25rem; + height: 1.25rem; + margin-top: 0.0625rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid var(--bs-border-color); + border-radius: 0.25rem; + background-color: var(--bs-tertiary-bg); + color: var(--bs-secondary-color); + line-height: 1; + cursor: pointer; +} + +.execution-tree-toggle:hover { + background-color: var(--bs-secondary-bg); + color: var(--bs-body-color); +} + +.execution-tree-toggle--leaf { + border-color: transparent; + background-color: transparent; + cursor: default; +} + +.execution-tree-toggle-glyph { + font-size: 0.875rem; + font-weight: 600; +} + +.execution-tree-body { + flex: 1 1 auto; + min-width: 0; +} + +/* Headline row: short id link, tags, row count. */ +.execution-tree-headline { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.execution-tree-link { + font-size: 0.875rem; + font-weight: 600; + text-decoration: none; +} + +.execution-tree-link:hover { + text-decoration: underline; +} + +.execution-tree-tag { + font-weight: 500; + font-size: 0.6875rem; +} + +.execution-tree-rowcount { + margin-left: auto; +} + +/* Meta row: source / channels / statuses / time span, pipe-separated visually + via spacing rather than literal separators. */ +.execution-tree-meta { + margin-top: 0.25rem; + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + color: var(--bs-body-color); +} + +.execution-tree-meta-item .text-muted { + margin-right: 0.25rem; + text-transform: uppercase; + font-size: 0.6875rem; + letter-spacing: 0.02em; +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor new file mode 100644 index 0000000..1883faa --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor @@ -0,0 +1,63 @@ +@page "/audit/execution-tree" +@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)] +@using ScadaLink.CentralUI.Components.Audit +@using ScadaLink.CentralUI.Services +@using ScadaLink.Commons.Types.Audit +@using ScadaLink.Security +@inject IAuditLogQueryService AuditLogQueryService + +Execution Chain + +@* Execution-chain tree view (Audit Log ParentExecutionId feature, Task 10). + A drill-in target reached from the Audit Log drawer's "View execution chain" + action: /audit/execution-tree?executionId={guid}. The page parses the id, + asks the query service for the whole chain (flat ExecutionTreeNode list), and + hands it to the recursive ExecutionTree component. There is deliberately NO + nav-menu entry — this page is only meaningful in the context of a specific + execution, so it is reachable only via drill-in (the Audit nav group keeps + just the Audit Log + Configuration Audit Log pages). *@ + +
+

Execution Chain

+

+ The full chain of script / inbound-request executions linked by + ParentExecutionId, rooted at the + topmost ancestor. Select an execution to open the Audit Log filtered to + its rows. +

+ + @if (_executionId is null) + { + @* No (or unparseable) ?executionId= — render guidance rather than an + empty tree. Mirrors the Audit Log page's silently-drop contract. *@ +
+ No execution selected. Open this view from an audit row's + View execution chain action. +
+ } + else if (_loading) + { +
Loading execution chain…
+ } + else if (_error is not null) + { +
@_error
+ } + else if (_nodes is { Count: > 0 }) + { + + + } + else + { +
+ No execution chain found for this id. +
+ } +
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs new file mode 100644 index 0000000..84ac269 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; +using ScadaLink.Commons.Types.Audit; + +namespace ScadaLink.CentralUI.Components.Pages.Audit; + +/// +/// Code-behind for the execution-chain tree page (Audit Log ParentExecutionId +/// feature, Task 10). Route /audit/execution-tree, reached via the Audit +/// Log drilldown drawer's "View execution chain" action with +/// ?executionId={guid}. +/// +/// +/// On initialization the page parses ?executionId= (lax-parsed, matching +/// the Audit Log page's drill-in contract — an absent or unparseable value +/// leaves the page in a guidance state and issues NO service call), then asks +/// +/// for the whole chain. The flat list is handed +/// to the recursive ExecutionTree component, which assembles + renders +/// the tree. +/// +/// +/// +/// The data path mirrors the Audit Log results grid: the page talks ONLY to the +/// CentralUI IAuditLogQueryService facade, never IAuditLogRepository +/// directly, so the page can be unit-tested with a substituted service. +/// +/// +public partial class ExecutionTreePage +{ + [Inject] private NavigationManager Navigation { get; set; } = null!; + + // The parsed ?executionId= value, or null when absent / unparseable. + private Guid? _executionId; + + // The flat chain returned by the query service; null until the load + // completes (or when no id was supplied). + private IReadOnlyList? _nodes; + + private bool _loading; + private string? _error; + + protected override async Task OnInitializedAsync() + { + _executionId = ParseExecutionId(); + if (_executionId is null) + { + // No id — render guidance, do not touch the service. + return; + } + + await LoadChainAsync(_executionId.Value); + } + + /// + /// Lax-parses ?executionId=. Returns null when the param is absent or + /// is not a valid — the page then shows guidance instead + /// of an error, consistent with the Audit Log page's drill-in handling. + /// + private Guid? ParseExecutionId() + { + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + if (query.TryGetValue("executionId", out var values) + && Guid.TryParse(values.ToString(), out var parsed)) + { + return parsed; + } + return null; + } + + private async Task LoadChainAsync(Guid executionId) + { + _loading = true; + _error = null; + try + { + _nodes = await AuditLogQueryService.GetExecutionTreeAsync(executionId); + } + catch (Exception ex) + { + // A transient DB outage degrades this page to an error banner + // rather than killing the circuit — the same defensive posture the + // Audit Log grid takes around its query. + _error = $"Could not load the execution chain: {ex.Message}"; + _nodes = null; + } + finally + { + _loading = false; + } + } +} diff --git a/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs index f4bd2f4..346a8a9 100644 --- a/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs +++ b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs @@ -132,4 +132,23 @@ public sealed class AuditLogQueryService : IAuditLogQueryService return repoSnapshot with { BacklogTotal = backlog }; } + + /// + public async Task> GetExecutionTreeAsync( + Guid executionId, + CancellationToken ct = default) + { + // Test-seam ctor: use the injected repository directly. + if (_injectedRepository is not null) + { + return await _injectedRepository.GetExecutionTreeAsync(executionId, ct); + } + + // Production: a fresh scope (and thus a fresh DbContext) per call — the + // same context-isolation contract QueryAsync upholds, so the tree + // page's auto-load never shares the circuit-scoped context. + await using var scope = _scopeFactory!.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + return await repository.GetExecutionTreeAsync(executionId, ct); + } } diff --git a/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs index 08b85d8..e802ec5 100644 --- a/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs +++ b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs @@ -50,4 +50,23 @@ public interface IAuditLogQueryService /// dashboard. /// Task GetKpiSnapshotAsync(CancellationToken ct = default); + + /// + /// Audit Log ParentExecutionId feature (Task 10) — returns the full + /// execution chain containing as a flat list + /// of , delegating to + /// . + /// The execution-chain tree view (/audit/execution-tree) assembles the + /// returned flat list into a tree by joining + /// to a parent node's + /// . + /// + /// + /// A pure pass-through, mirroring — the production + /// implementation opens its own DI scope per call so the tree page's + /// auto-load never contends with the circuit-scoped ScadaLinkDbContext. + /// + Task> GetExecutionTreeAsync( + Guid executionId, + CancellationToken ct = default); } diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs index 4fefe5d..256c7d1 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs @@ -429,6 +429,88 @@ public class AuditLogPageTests } } + [Fact] + public async Task DrillInToExecutionChain_RendersTree_AndNodeClickFiltersGrid() + { + // Audit Log ParentExecutionId feature, Task 10: the drawer's "View + // execution chain" action opens /audit/execution-tree?executionId={id}. + // We seed a spawner row + a child row, open the child's drawer, click + // "View execution chain", assert the tree renders BOTH executions, then + // click the spawner node and assert the Audit Log grid filters to it. + if (!await AuditDataSeeder.IsAvailableAsync()) + { + throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions."); + } + + var runId = Guid.NewGuid().ToString("N"); + var targetPrefix = $"playwright-test/exec-chain-tree/{runId}/"; + var parentExecutionId = Guid.NewGuid(); + var childExecutionId = Guid.NewGuid(); + var spawnerEventId = Guid.NewGuid(); + var childEventId = Guid.NewGuid(); + var now = DateTime.UtcNow; + + try + { + // Spawner execution's own row. + await AuditDataSeeder.InsertAuditEventAsync( + eventId: spawnerEventId, + occurredAtUtc: now, + channel: "ApiInbound", + kind: "InboundRequest", + status: "Delivered", + target: targetPrefix + "spawner", + executionId: parentExecutionId, + httpStatus: 200, + durationMs: 7); + + // Child (spawned) row — links to the spawner via ParentExecutionId. + await AuditDataSeeder.InsertAuditEventAsync( + eventId: childEventId, + occurredAtUtc: now, + channel: "ApiOutbound", + kind: "ApiCall", + status: "Delivered", + target: targetPrefix + "child", + executionId: childExecutionId, + parentExecutionId: parentExecutionId, + httpStatus: 200, + durationMs: 13); + + var page = await _fixture.NewAuthenticatedPageAsync(); + + // Open the child row's drawer via its ExecutionId filter. + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?executionId={childExecutionId}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var childRow = page.Locator($"[data-test='grid-row-{childEventId}']"); + await Assertions.Expect(childRow).ToBeVisibleAsync(); + await childRow.ClickAsync(); + + // "View execution chain" opens the tree view. + var viewChain = page.Locator("[data-test='view-execution-chain']"); + await Assertions.Expect(viewChain).ToBeVisibleAsync(); + await viewChain.ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // The tree page rendered both executions as nodes. + Assert.Contains($"executionId={childExecutionId}", page.Url); + await Assertions.Expect(page.Locator($"[data-test='tree-node-{parentExecutionId}']")).ToBeVisibleAsync(); + await Assertions.Expect(page.Locator($"[data-test='tree-node-{childExecutionId}']")).ToBeVisibleAsync(); + + // Clicking the spawner node's link filters the Audit Log to its rows. + await page.Locator($"[data-test='tree-node-link-{parentExecutionId}']").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + Assert.Contains($"executionId={parentExecutionId}", page.Url); + await Assertions.Expect(page.Locator($"[data-test='grid-row-{spawnerEventId}']")).ToBeVisibleAsync(); + } + finally + { + await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); + } + } + [Fact] public async Task NotificationsPage_RendersAuditDrillInLinkPattern() { diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs index 15bcf62..78608cd 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs @@ -303,6 +303,48 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains($"/audit/log?executionId={parent}", nav.Uri); } + [Fact] + public void Drawer_NullExecutionId_HidesViewExecutionChainButton() + { + var ev = MakeEvent(executionId: null); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + Assert.DoesNotContain("data-test=\"view-execution-chain\"", cut.Markup); + } + + [Fact] + public void Drawer_NonNullExecutionId_ShowsViewExecutionChainButton() + { + var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-9999-8888-7777-666666666666")); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + Assert.Contains("data-test=\"view-execution-chain\"", cut.Markup); + } + + [Fact] + public void ViewExecutionChain_Navigates_ToExecutionTreePage() + { + // The "View execution chain" action opens the tree view rooted at the + // chain containing this row's ExecutionId. + var exec = Guid.Parse("12345678-aaaa-bbbb-cccc-1234567890ab"); + var ev = MakeEvent(executionId: exec); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + cut.Find("[data-test=\"view-execution-chain\"]").Click(); + + var nav = (BunitNavigationManager)Services.GetRequiredService(); + Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri); + } + [Fact] public async Task CopyAsCurl_InvokesClipboard_WithCurlString() { diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs new file mode 100644 index 0000000..5da931e --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs @@ -0,0 +1,197 @@ +using Bunit; +using ScadaLink.CentralUI.Components.Audit; +using ScadaLink.Commons.Types.Audit; + +namespace ScadaLink.CentralUI.Tests.Components.Audit; + +/// +/// bUnit tests for (Audit Log ParentExecutionId +/// feature, Task 10). The component takes the FLAT +/// list the repository returns, assembles it +/// into a tree by joining to a +/// parent node's , and renders it +/// recursively. Tests pin: single-node tree, multi-level assembly, stub-node +/// presentation, the arrived-from highlight, node-click navigation, and +/// cycle-safety (a corrupt flat list must not infinite-loop). +/// +public class ExecutionTreeTests : BunitContext +{ + private static ExecutionTreeNode Node( + Guid executionId, + Guid? parentExecutionId, + int rowCount = 2, + string? site = "plant-a", + string? instance = "boiler-3") + => new( + executionId, + parentExecutionId, + rowCount, + rowCount == 0 ? Array.Empty() : new[] { "ApiOutbound" }, + rowCount == 0 ? Array.Empty() : new[] { "Delivered" }, + rowCount == 0 ? null : site, + rowCount == 0 ? null : instance, + rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc)); + + [Fact] + public void SingleNode_RendersOneTreeNode() + { + var id = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var nodes = new List { Node(id, null) }; + + var cut = Render(p => p + .Add(c => c.Nodes, nodes) + .Add(c => c.ArrivedFromExecutionId, id)); + + Assert.Contains($"data-test=\"tree-node-{id}\"", cut.Markup); + } + + [Fact] + public void MultiLevel_AssemblesTree_FromFlatList() + { + // root → child → grandchild — a deliberately shuffled flat list so the + // component must reconstruct parent/child links rather than rely on + // input ordering. + var root = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000"); + var child = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000000"); + var grandchild = Guid.Parse("cccccccc-0000-0000-0000-000000000000"); + var nodes = new List + { + Node(grandchild, child), + Node(root, null), + Node(child, root), + }; + + var cut = Render(p => p + .Add(c => c.Nodes, nodes) + .Add(c => c.ArrivedFromExecutionId, child)); + + // All three executions render as nodes. + Assert.Contains($"data-test=\"tree-node-{root}\"", cut.Markup); + Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup); + Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup); + + // The root must appear before the child, and the child before the + // grandchild — recursive depth-first rendering preserves ancestry. + var rootIdx = cut.Markup.IndexOf($"tree-node-{root}", StringComparison.Ordinal); + var childIdx = cut.Markup.IndexOf($"tree-node-{child}", StringComparison.Ordinal); + var grandIdx = cut.Markup.IndexOf($"tree-node-{grandchild}", StringComparison.Ordinal); + Assert.True(rootIdx < childIdx, "root must render before child"); + Assert.True(childIdx < grandIdx, "child must render before grandchild"); + } + + [Fact] + public void StubNode_RendersStubMarker() + { + // A stub parent (RowCount = 0) referenced by a real child must still + // render, visibly marked as "no audited actions". + var stubParent = Guid.Parse("dddddddd-0000-0000-0000-000000000000"); + var child = Guid.Parse("eeeeeeee-0000-0000-0000-000000000000"); + var nodes = new List + { + Node(stubParent, null, rowCount: 0), + Node(child, stubParent), + }; + + var cut = Render(p => p + .Add(c => c.Nodes, nodes) + .Add(c => c.ArrivedFromExecutionId, child)); + + Assert.Contains($"data-test=\"tree-node-{stubParent}\"", cut.Markup); + Assert.Contains($"data-test=\"stub-node-{stubParent}\"", cut.Markup); + Assert.Contains("no audited actions", cut.Markup); + } + + [Fact] + public void ArrivedFromNode_IsVisuallyHighlighted() + { + var root = Guid.Parse("aaaaaaaa-1111-1111-1111-111111111111"); + var child = Guid.Parse("bbbbbbbb-1111-1111-1111-111111111111"); + var nodes = new List + { + Node(root, null), + Node(child, root), + }; + + var cut = Render(p => p + .Add(c => c.Nodes, nodes) + .Add(c => c.ArrivedFromExecutionId, child)); + + // The arrived-from node carries the highlight marker; a non-arrived + // sibling does not. + var arrived = cut.Find($"[data-test=\"tree-node-{child}\"]"); + Assert.Contains("execution-tree-node--current", arrived.GetAttribute("class")); + + var other = cut.Find($"[data-test=\"tree-node-{root}\"]"); + Assert.DoesNotContain("execution-tree-node--current", other.GetAttribute("class") ?? string.Empty); + } + + [Fact] + public void NodeLink_PointsTo_AuditLogFilteredByThatExecution() + { + // Each node's id is a real deep link — clicking it lands on + // the Audit Log filtered to that execution's rows. A genuine anchor + // (rather than an @onclick navigate) keeps the link middle-click / + // open-in-new-tab friendly, matching the rest of the Audit UI. + var root = Guid.Parse("aaaaaaaa-2222-2222-2222-222222222222"); + var child = Guid.Parse("bbbbbbbb-2222-2222-2222-222222222222"); + var nodes = new List + { + Node(root, null), + Node(child, root), + }; + + var cut = Render(p => p + .Add(c => c.Nodes, nodes) + .Add(c => c.ArrivedFromExecutionId, root)); + + var childLink = cut.Find($"[data-test=\"tree-node-link-{child}\"]"); + Assert.Equal($"/audit/log?executionId={child}", childLink.GetAttribute("href")); + + var rootLink = cut.Find($"[data-test=\"tree-node-link-{root}\"]"); + Assert.Equal($"/audit/log?executionId={root}", rootLink.GetAttribute("href")); + } + + [Fact] + public void EmptyNodeList_RendersNothingWithoutThrowing() + { + var cut = Render(p => p + .Add(c => c.Nodes, (IReadOnlyList)Array.Empty()) + .Add(c => c.ArrivedFromExecutionId, Guid.NewGuid())); + + Assert.DoesNotContain("data-test=\"tree-node-", cut.Markup); + } + + [Fact] + public void CyclicFlatList_TerminatesWithoutInfiniteLoop() + { + // Defensive: a corrupt flat list where A→B and B→A must not hang the + // renderer. Each execution is rendered at most once. + var a = Guid.Parse("a0000000-0000-0000-0000-000000000000"); + var b = Guid.Parse("b0000000-0000-0000-0000-000000000000"); + var nodes = new List + { + Node(a, b), + Node(b, a), + }; + + var cut = Render(p => p + .Add(c => c.Nodes, nodes) + .Add(c => c.ArrivedFromExecutionId, a)); + + // Both render exactly once — no runaway recursion. + Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{a}\"")); + Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{b}\"")); + } + + private static int CountOccurrences(string haystack, string needle) + { + int count = 0, idx = 0; + while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0) + { + count++; + idx += needle.Length; + } + return count; + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs new file mode 100644 index 0000000..c69d10b --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs @@ -0,0 +1,124 @@ +using System.Security.Claims; +using Bunit; +using Bunit.TestDoubles; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ScadaLink.CentralUI.Services; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Security; +using ExecutionTreePage = ScadaLink.CentralUI.Components.Pages.Audit.ExecutionTreePage; + +namespace ScadaLink.CentralUI.Tests.Pages; + +/// +/// bUnit tests for (Audit Log ParentExecutionId +/// feature, Task 10). The page is reached via the "View execution chain" +/// drill-in at /audit/execution-tree?executionId={guid}. It parses the +/// query-string id, calls , +/// and hands the flat node list to the ExecutionTree component. +/// +public class ExecutionTreePageTests : BunitContext +{ + private IAuditLogQueryService _queryService = Substitute.For(); + + private static ClaimsPrincipal BuildPrincipal(params string[] roles) + { + var claims = new List { new("Username", "tester") }; + claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r))); + return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + } + + private IRenderedComponent RenderPage(string? query, params string[] roles) + { + var user = BuildPrincipal(roles); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + AuthorizationPolicies.AddScadaLinkAuthorization(Services); + Services.AddSingleton(); + Services.AddSingleton(_queryService); + + if (!string.IsNullOrEmpty(query)) + { + var nav = (BunitNavigationManager)Services.GetRequiredService(); + nav.NavigateTo($"/audit/execution-tree?{query}"); + } + + var host = Render(parameters => parameters + .Add(p => p.ChildContent, (RenderFragment)(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }))); + + return host.FindComponent(); + } + + private static ExecutionTreeNode Node(Guid id, Guid? parent, int rowCount = 2) + => new( + id, parent, rowCount, + rowCount == 0 ? Array.Empty() : new[] { "ApiOutbound" }, + rowCount == 0 ? Array.Empty() : new[] { "Delivered" }, + rowCount == 0 ? null : "plant-a", + rowCount == 0 ? null : "boiler-3", + rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc)); + + [Fact] + public void NavigateWithExecutionId_CallsService_AndRendersTree() + { + var root = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var child = Guid.Parse("22222222-2222-2222-2222-222222222222"); + _queryService = Substitute.For(); + _queryService.GetExecutionTreeAsync(child, Arg.Any()) + .Returns(Task.FromResult>(new List + { + Node(root, null), + Node(child, root), + })); + + var cut = RenderPage($"executionId={child}", "Admin"); + + cut.WaitForAssertion(() => + { + _queryService.Received().GetExecutionTreeAsync(child, Arg.Any()); + Assert.Contains($"data-test=\"tree-node-{root}\"", cut.Markup); + Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup); + }); + } + + [Fact] + public void NavigateWithoutExecutionId_RendersGuidancePrompt_NoServiceCall() + { + _queryService = Substitute.For(); + + var cut = RenderPage(query: null, "Admin"); + + cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup)); + _queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void NavigateWithUnparseableExecutionId_RendersGuidancePrompt_NoServiceCall() + { + _queryService = Substitute.For(); + + var cut = RenderPage("executionId=not-a-guid", "Admin"); + + cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup)); + _queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void ExecutionTreePage_HasOperationalAuditAuthorizeAttribute() + { + var attributes = typeof(ExecutionTreePage) + .GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true) + .Cast() + .ToList(); + + Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs index 03e0e82..f947dd9 100644 --- a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs @@ -222,6 +222,66 @@ public class AuditLogQueryServiceTests Assert.NotSame(resolvedRepos[0], resolvedRepos[1]); } + // ───────────────────────────────────────────────────────────────────────── + // Audit Log ParentExecutionId feature (Task 10): GetExecutionTreeAsync — + // a thin pass-through over IAuditLogRepository.GetExecutionTreeAsync, mirroring + // QueryAsync's scope-per-call contract on the production path. + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetExecutionTreeAsync_ForwardsExecutionId_ToRepository() + { + var repo = Substitute.For(); + var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + var expected = new List + { + new(executionId, null, 3, + new[] { "ApiOutbound" }, new[] { "Delivered" }, + "plant-a", "boiler-3", + new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc)), + }; + repo.GetExecutionTreeAsync(executionId, Arg.Any()) + .Returns(Task.FromResult>(expected)); + + var sut = new AuditLogQueryService(repo, EmptyAggregator()); + + var result = await sut.GetExecutionTreeAsync(executionId); + + Assert.Same(expected, result); + await repo.Received(1).GetExecutionTreeAsync(executionId, Arg.Any()); + } + + [Fact] + public async Task GetExecutionTreeAsync_OpensFreshScopePerCall_OnProductionCtor() + { + // The production ctor must resolve a fresh repository per call — same + // scope-per-query contract QueryAsync upholds, so the page's auto-load + // never shares the circuit-scoped DbContext. + var resolvedRepos = new List(); + + var services = new ServiceCollection(); + services.AddScoped(_ => + { + var repo = Substitute.For(); + repo.GetExecutionTreeAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + resolvedRepos.Add(repo); + return repo; + }); + + await using var provider = services.BuildServiceProvider(); + var sut = new AuditLogQueryService( + provider.GetRequiredService(), + EmptyAggregator()); + + await sut.GetExecutionTreeAsync(Guid.NewGuid()); + await sut.GetExecutionTreeAsync(Guid.NewGuid()); + + Assert.Equal(2, resolvedRepos.Count); + Assert.NotSame(resolvedRepos[0], resolvedRepos[1]); + } + private static SiteHealthState StateWithBacklog(string siteId, int? pending) { SiteAuditBacklogSnapshot? backlog = pending.HasValue