From 2d1388628628ce4ed73abd6cfdad2ce1afb83563 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 19:43:30 -0400 Subject: [PATCH 01/14] docs(audit): add M7 Central UI implementation plan (#23) 8 bundles: page scaffold + rename, filter bar + grid, drilldown drawer, drill-ins, KPI tiles, CSV export, permissions, Playwright. UI memory constraints locked: Blazor Server + Bootstrap, no third-party libs. --- .../2026-05-20-auditlog-m7-central-ui.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/plans/2026-05-20-auditlog-m7-central-ui.md diff --git a/docs/plans/2026-05-20-auditlog-m7-central-ui.md b/docs/plans/2026-05-20-auditlog-m7-central-ui.md new file mode 100644 index 0000000..ba2765a --- /dev/null +++ b/docs/plans/2026-05-20-auditlog-m7-central-ui.md @@ -0,0 +1,31 @@ +# Audit Log #23 — M7 Central UI Implementation Plan + +> **For Claude:** subagent-driven-development with bundled cadence. + +**Goal:** User-visible Audit Log page in the Central UI: filter bar, results grid with keyset paging, drilldown drawer with JSON pretty-print + cURL + redaction badges, drill-ins from Notifications/Site Calls/External Systems/Inbound API Keys/Sites/Instances, 3 KPI tiles on Health dashboard, server-side streaming CSV export, OperationalAudit+AuditExport permission gating, Playwright E2E. + +**UI memory constraints (locked):** +- Blazor Server + Bootstrap CSS only. NO third-party UI libraries (no Blazorise, MudBlazor, Radzen, Prism.js, Highlight.js, etc.). +- Custom Blazor components for tables/grids/forms. +- Clean corporate aesthetic. +- Form layout: vertical stacking, read-only fields first, subsections stacked, buttons at bottom. +- Use the frontend-design skill IF dispatched UI-design subagents need pattern guidance. + +**M6 realities baked in:** +- `IAuditCentralHealthSnapshot` aggregates CentralAuditWriteFailures + AuditRedactionFailure + per-site stalled. Health tiles read this. +- `SiteHealthReport.SiteAuditBacklog` ready for per-site display. +- `IAuditLogRepository.QueryAsync` keyset-paged; data source for the grid. +- Pre-existing `Components/Pages/Monitoring/AuditLog.razor` (IAuditService config-change viewer) must be renamed → `Components/Pages/Audit/ConfigurationAuditLog.razor` with route `/audit/configuration`. Old route returns 404 (no redirect — internal tool, no external bookmarks). +- Need to add `OperationalAudit` + `AuditExport` permission strings. + +**SQL highlighting decision:** no third-party highlighter. Render `
` block with `language-sql` class and let any future CSS theme it; semantic markup is preserved without JS dependency.
+
+**Bundles:**
+- Bundle A — Page scaffold + nav + ConfigurationAuditLog rename (T1, T9)
+- Bundle B — Filter bar + results grid (T2, T3)
+- Bundle C — Drilldown drawer (T4, T5, T6, T7, T8)
+- Bundle D — Drill-ins from other pages (T10, T11, T12)
+- Bundle E — Health dashboard KPI tiles (T13)
+- Bundle F — CSV export (T14)
+- Bundle G — Permissions (T15)
+- Bundle H — Playwright E2E (T16)

From a9f45b0861e451f4c45a141b3368f9a856795cc2 Mon Sep 17 00:00:00 2001
From: Joseph Doherty 
Date: Wed, 20 May 2026 19:46:09 -0400
Subject: [PATCH 02/14] refactor(ui): rename AuditLog viewer to
 ConfigurationAuditLog under /audit/configuration (#23 M7)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The pre-M1 IAuditService config-change viewer moves out of the Monitoring
nav group to make room for the new Audit nav group (issue #23 M7). The
old route /monitoring/audit-log returns 404 (no redirect, per plan) — the
viewer is now reachable at /audit/configuration and labelled
"Configuration Audit Log" to disambiguate from the new Audit Log page
(arriving in #23 M7-T9). Inbound references in NavMenu, Dashboard, and
the Playwright nav tests are updated to the new route and label.
---
 src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor     | 4 ++--
 .../AuditLog.razor => Audit/ConfigurationAuditLog.razor}    | 4 ++--
 src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor    | 4 ++--
 .../ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs  | 1 -
 .../RoleNavigationTests.cs                                  | 6 +++---
 5 files changed, 9 insertions(+), 10 deletions(-)
 rename src/ScadaLink.CentralUI/Components/Pages/{Monitoring/AuditLog.razor => Audit/ConfigurationAuditLog.razor} (99%)

diff --git a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor
index 78fb4f2..851c4f7 100644
--- a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor
+++ b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor
@@ -108,11 +108,11 @@
                         
                     
 
-                    @* Audit Log — Admin only *@
+                    @* Configuration Audit Log — Admin only *@
                     
                         
                             
                         
                     
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/AuditLog.razor b/src/ScadaLink.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor
similarity index 99%
rename from src/ScadaLink.CentralUI/Components/Pages/Monitoring/AuditLog.razor
rename to src/ScadaLink.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor
index 96d7108..df5e58c 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/AuditLog.razor
+++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor
@@ -1,4 +1,4 @@
-@page "/monitoring/audit-log"
+@page "/audit/configuration"
 @using ScadaLink.Security
 @using ScadaLink.CentralUI.Components
 @using ScadaLink.Commons.Entities.Audit
@@ -8,7 +8,7 @@
 @inject IJSRuntime JS
 
 
-

Audit Log

+

Configuration Audit Log

diff --git a/src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor b/src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor index ce24f60..7935084 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor @@ -70,10 +70,10 @@
- +
-
Recent Audit Log
+
Configuration Audit Log

Browse changes to configuration and deployments.

diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs index acbc746..962b641 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs @@ -68,7 +68,6 @@ public class NavigationTests [InlineData("Health Dashboard", "/monitoring/health")] [InlineData("Event Logs", "/monitoring/event-logs")] [InlineData("Parked Messages", "/monitoring/parked-messages")] - [InlineData("Audit Log", "/monitoring/audit-log")] public async Task MonitoringNavLinks_NavigateCorrectly(string linkText, string expectedPath) { var page = await _fixture.NewAuthenticatedPageAsync(); diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/RoleNavigationTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/RoleNavigationTests.cs index e394edf..508ec56 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/RoleNavigationTests.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/RoleNavigationTests.cs @@ -158,16 +158,16 @@ public class RoleNavigationTests } [Fact] - public async Task DeploymentUser_SeesMonitoringButNotAuditLog() + public async Task DeploymentUser_SeesMonitoringButNotConfigurationAuditLog() { var page = await _fixture.NewAuthenticatedPageAsync("deployer", "password"); // Event Logs and Parked Messages are Deployment-role gated, so a - // Deployment user sees them; Audit Log is Admin-only. + // Deployment user sees them; Configuration Audit Log is Admin-only. await AssertNavLinkVisible(page, "Health Dashboard"); await AssertNavLinkVisible(page, "Event Logs"); await AssertNavLinkVisible(page, "Parked Messages"); - await AssertNavLinkHidden(page, "Audit Log"); + await AssertNavLinkHidden(page, "Configuration Audit Log"); } // ── Multi-role user (Admin + Design + Deployment) ─────────────── From 12b86bea7aca2e13a0bd13f8262274c0efff02d3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 19:49:11 -0400 Subject: [PATCH 03/14] feat(ui): scaffold Audit Log page + Audit nav group (#23 M7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the central-side Audit Log page scaffold at /audit/log (M7-T1) and introduces a new Audit nav group (M7-T9) that hosts both the new page and the renamed Configuration Audit Log. The page body is intentionally a heading + two placeholders — Bundle B will land the AuditFilterBar and AuditResultsGrid behind them. The Audit nav group sits between Monitoring and the per-user footer; both items inside are Admin-only, so the section header lives inside the RequireAdmin AuthorizeView (non-admins see no orphan header). bUnit scaffold tests pin the page heading, the section header order, and the two child links; the existing 338 CentralUI tests continue to pass. --- .../Components/Layout/NavMenu.razor | 9 +- .../Components/Pages/Audit/AuditLogPage.razor | 16 +++ .../Pages/Audit/AuditLogPage.razor.cs | 12 ++ .../Pages/AuditLogPageScaffoldTests.cs | 108 ++++++++++++++++++ 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor create mode 100644 src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor index 851c4f7..798d306 100644 --- a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor @@ -108,9 +108,16 @@ - @* Configuration Audit Log — Admin only *@ + @* Audit — Admin only. Hosts the new Audit Log page (#23 M7) + and the renamed Configuration Audit Log (IAuditService + config-change viewer). Both items are Admin-gated, so + the section header sits inside the same policy block. *@ + + diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor new file mode 100644 index 0000000..8d250e7 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor @@ -0,0 +1,16 @@ +@page "/audit/log" +@attribute [Authorize] + +Audit Log + +
+

Audit Log

+ + @* AuditFilterBar will go here (Bundle B). *@ +
+
+ + @* AuditResultsGrid will go here (Bundle B). *@ +
+
+
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs new file mode 100644 index 0000000..311f369 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs @@ -0,0 +1,12 @@ +namespace ScadaLink.CentralUI.Components.Pages.Audit; + +/// +/// Code-behind for the central Audit Log page (#23 M7-T1). The Bundle A +/// scaffold has no behaviour — the filter bar and results grid arrive in +/// Bundle B (M7-T2..M7-T7). Keeping the partial class in place now lets +/// later bundles add injected services and event handlers without +/// touching the route or page-title markup. +/// +public partial class AuditLogPage +{ +} diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs new file mode 100644 index 0000000..3ab4892 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs @@ -0,0 +1,108 @@ +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.Security; +using AuditLogPage = ScadaLink.CentralUI.Components.Pages.Audit.AuditLogPage; +using NavMenu = ScadaLink.CentralUI.Components.Layout.NavMenu; + +namespace ScadaLink.CentralUI.Tests.Pages; + +/// +/// Scaffold tests for the new Audit Log page (#23 M7-T1) and the Audit +/// nav group that hosts both it and the renamed Configuration Audit Log +/// (#23 M7 Bundle A). +/// +/// These are render-only smoke tests — the filter bar and results grid +/// are intentional placeholders that Bundle B fills in. The tests pin +/// the page route, page heading, nav group label, and the two child +/// links so later bundles cannot regress the scaffolding. +/// +public class AuditLogPageScaffoldTests : BunitContext +{ + 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 RenderAuditLogPage(params string[] roles) + { + var user = BuildPrincipal(roles); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + AuthorizationPolicies.AddScadaLinkAuthorization(Services); + Services.AddSingleton(); + return Render(); + } + + private IRenderedComponent RenderNavMenu(params string[] roles) + { + var user = BuildPrincipal(roles); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + AuthorizationPolicies.AddScadaLinkAuthorization(Services); + Services.AddSingleton(); + + var host = Render(parameters => parameters + .Add(p => p.ChildContent, (RenderFragment)(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }))); + + return host.FindComponent(); + } + + [Fact] + public void AuditLogPage_Renders_PageHeading() + { + var cut = RenderAuditLogPage("Admin"); + + cut.WaitForAssertion(() => + { + // The H1 is the only positive scaffold assertion — the filter + // bar and grid are still placeholders the Bundle B work fills. + Assert.Contains(" + { + Assert.Contains(">Audit<", cut.Markup); + Assert.Contains("/audit/log", cut.Markup); + }); + } + + [Fact] + public void NavMenu_Contains_ConfigurationAuditLog_Link_UnderAuditGroup() + { + var cut = RenderNavMenu("Admin", "Design", "Deployment"); + + cut.WaitForAssertion(() => + { + // Both audit pages must appear after the Audit section header + // in the rendered nav. We check both links + that the header + // comes before either link in the markup, so they are in the + // Audit group rather than orphaned under Monitoring. + Assert.Contains("/audit/configuration", cut.Markup); + Assert.Contains("/audit/log", cut.Markup); + var headerIdx = cut.Markup.IndexOf(">Audit<", StringComparison.Ordinal); + var configIdx = cut.Markup.IndexOf("/audit/configuration", StringComparison.Ordinal); + var logIdx = cut.Markup.IndexOf("/audit/log", StringComparison.Ordinal); + Assert.True(headerIdx >= 0 && headerIdx < configIdx, + "Audit section header must precede the Configuration Audit Log link."); + Assert.True(headerIdx >= 0 && headerIdx < logIdx, + "Audit section header must precede the Audit Log link."); + }); + } +} From 13e84a76a7b53e0c6eb4e1d9bbced0cbfc8cfdec Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 19:56:49 -0400 Subject: [PATCH 04/14] feat(ui): AuditFilterBar component (#23 M7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the filter bar for the central Audit Log page (#23 M7-T2): * AuditQueryModel — UI binding model with chip-style multi-select state for Channel/Kind/Status/Site, a Channel→Kind narrowing map (CachedSubmit and CachedResolve appear under both ApiOutbound and DbOutbound per Component-AuditLog.md §4), time-range presets (5min/1h/24h/Custom), free-text Instance/Script/Target/Actor searches and an Errors-only toggle. Collapses to the single-value AuditLogQueryFilter on ToFilter(utcNow); multi-select chips take the first selected per dimension and the Errors-only toggle pins Failed when Status chips are empty (chip-set wins otherwise) — documented Bundle B scope decision. * AuditFilterBar.razor + .razor.cs — Blazor Server component (Bootstrap only, no third-party UI libs). Renders the 10 spec elements plus the Errors-only toggle, populates Site chips from ISiteRepository at initialisation, exposes [Parameter] EventCallback OnFilterChanged and an optional NowUtcProvider seam for time-window tests. * AuditFilterBarTests — 5 bUnit tests pinning element presence, Apply callback payload, Channel→Kind narrowing, Errors-only toggle precedence and the LastHour time-window collapse. --- .../Components/Audit/AuditFilterBar.razor | 156 ++++++++++++++++ .../Components/Audit/AuditFilterBar.razor.cs | 126 +++++++++++++ .../Components/Audit/AuditQueryModel.cs | 171 ++++++++++++++++++ .../Components/Audit/AuditFilterBarTests.cs | 149 +++++++++++++++ 4 files changed, 602 insertions(+) create mode 100644 src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor create mode 100644 src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs create mode 100644 src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor new file mode 100644 index 0000000..8601e5f --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor @@ -0,0 +1,156 @@ +@using ScadaLink.Commons.Entities.Sites +@using ScadaLink.Commons.Interfaces.Repositories +@using ScadaLink.Commons.Types.Audit +@using ScadaLink.Commons.Types.Enums +@inject ISiteRepository SiteRepository + +
+
+ @* Channel chip multi-select. *@ +
+ +
+ @foreach (var channel in Enum.GetValues()) + { + var selected = _model.Channels.Contains(channel); + + } +
+
+ + @* Kind chip multi-select — narrowed by Channel selection. *@ +
+ +
+ @foreach (var kind in _model.VisibleKinds()) + { + var selected = _model.Kinds.Contains(kind); + + } +
+
+ + @* Status chip multi-select. *@ +
+ +
+ @foreach (var status in Enum.GetValues()) + { + var selected = _model.Statuses.Contains(status); + + } +
+
+ + @* Site chip multi-select — populated from ISiteRepository. *@ +
+ +
+ @if (_sites.Count == 0) + { + No sites available. + } + else + { + @foreach (var site in _sites) + { + var selected = _model.SiteIdentifiers.Contains(site.SiteIdentifier); + + } + } +
+
+ +
+
+ + +
+ + @* Custom datetime range; only the pickers are conditional, the wrapper is + always emitted so tests can find it. *@ +
+ @if (_model.TimeRange == AuditTimeRangePreset.Custom) + { +
+
+ + +
+
+ + +
+
+ } + else + { + Window: @TimeRangeLabel(_model.TimeRange) + } +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ +
+ + +
+
+
+
diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs new file mode 100644 index 0000000..40ab3f4 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs @@ -0,0 +1,126 @@ +using Microsoft.AspNetCore.Components; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Components.Audit; + +/// +/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the +/// binding state, renders the 10 filter elements +/// plus the Errors-only toggle, and publishes a collapsed +/// via when the +/// user clicks Apply. See for the multi-select → +/// single-value collapse contract. +/// +public partial class AuditFilterBar +{ + private readonly AuditQueryModel _model = new(); + private List _sites = new(); + + /// + /// Raised when the user clicks Apply. Carries the collapsed + /// the parent page hands to + /// . + /// + [Parameter] public EventCallback OnFilterChanged { get; set; } + + /// + /// Test seam: overriding "now" is needed to make the time-range collapse tests + /// stable in unit suites. Production callers leave this null and the model + /// uses . + /// + [Parameter] public Func? NowUtcProvider { get; set; } + + protected override async Task OnInitializedAsync() + { + // Populate the Site chips at component init. Failure is non-fatal — the chip + // section just shows "No sites available." Sites are listed by Name to match + // operator expectations from the Notification Report. + try + { + var sites = await SiteRepository.GetAllSitesAsync(); + _sites = sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + catch + { + // Swallowed: filter bar still renders without the Site chips. The page + // surfaces site-load errors elsewhere (the grid query path). + _sites = new(); + } + } + + private void ToggleChannel(AuditChannel channel) + { + if (!_model.Channels.Add(channel)) + { + _model.Channels.Remove(channel); + } + + // Drop Kind chips that fall outside the new visible set. Keeps "Channel and + // Kind both picked" coherent — without this, removing a channel could leave + // stale Kind chips selected that no longer match any visible chip. + var visible = _model.VisibleKinds().ToHashSet(); + _model.Kinds.RemoveWhere(k => !visible.Contains(k)); + } + + private void ToggleKind(AuditKind kind) + { + if (!_model.Kinds.Add(kind)) + { + _model.Kinds.Remove(kind); + } + } + + private void ToggleStatus(AuditStatus status) + { + if (!_model.Statuses.Add(status)) + { + _model.Statuses.Remove(status); + } + } + + private void ToggleSite(string siteIdentifier) + { + if (!_model.SiteIdentifiers.Add(siteIdentifier)) + { + _model.SiteIdentifiers.Remove(siteIdentifier); + } + } + + private void ClearFilters() + { + _model.Channels.Clear(); + _model.Kinds.Clear(); + _model.Statuses.Clear(); + _model.SiteIdentifiers.Clear(); + _model.TimeRange = AuditTimeRangePreset.LastHour; + _model.CustomFromUtc = null; + _model.CustomToUtc = null; + _model.InstanceSearch = string.Empty; + _model.ScriptSearch = string.Empty; + _model.TargetSearch = string.Empty; + _model.ActorSearch = string.Empty; + _model.ErrorsOnly = false; + } + + private async Task Apply() + { + var now = NowUtcProvider?.Invoke() ?? DateTime.UtcNow; + var filter = _model.ToFilter(now); + await OnFilterChanged.InvokeAsync(filter); + } + + private static string ChipClass(bool selected) => + selected + ? "btn btn-sm btn-primary me-1 mb-1" + : "btn btn-sm btn-outline-secondary me-1 mb-1"; + + private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch + { + AuditTimeRangePreset.Last5Minutes => "now − 5 min → now", + AuditTimeRangePreset.LastHour => "now − 1h → now", + AuditTimeRangePreset.Last24Hours => "now − 24h → now", + _ => "—", + }; +} diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs new file mode 100644 index 0000000..6ed9e70 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs @@ -0,0 +1,171 @@ +using System.Collections.Immutable; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Components.Audit; + +/// +/// UI-side binding model for (#23 M7-T2). +/// +/// +/// The model mirrors but allows multi-select chip +/// state for Channel / Kind / Status / Site (each a ) plus +/// extra UI-only fields the underlying filter does not carry: the Errors-only toggle, +/// the time-range preset, and free-text Instance / Script searches. +/// +/// +/// +/// The repository filter contract () is single-value +/// per dimension today; the chip multi-selects therefore collapse to the FIRST +/// selected chip when the model is published via . That is a +/// deliberate Bundle B scope decision — the chip UI is preserved so a follow-up can +/// either repeat the query per chip or widen the filter contract without rewriting +/// the form. Instance and Script free-text are also UI-only today: the underlying +/// filter has no matching columns, so they are dropped during collapse. +/// +/// +/// +/// The Errors-only toggle is a convenience: when true AND no explicit Status chips +/// are selected, the collapsed filter pins (the +/// first of {Failed, Parked, Discarded}). When Status chips ARE selected the toggle +/// is a no-op — the explicit Status filter wins. +/// +/// +public sealed class AuditQueryModel +{ + public HashSet Channels { get; } = new(); + public HashSet Kinds { get; } = new(); + public HashSet Statuses { get; } = new(); + public HashSet SiteIdentifiers { get; } = new(StringComparer.OrdinalIgnoreCase); + + public AuditTimeRangePreset TimeRange { get; set; } = AuditTimeRangePreset.LastHour; + public DateTime? CustomFromUtc { get; set; } + public DateTime? CustomToUtc { get; set; } + + public string InstanceSearch { get; set; } = string.Empty; + public string ScriptSearch { get; set; } = string.Empty; + public string TargetSearch { get; set; } = string.Empty; + public string ActorSearch { get; set; } = string.Empty; + + public bool ErrorsOnly { get; set; } + + /// + /// Maps each channel to the kinds it can emit (per Component-AuditLog.md §4). + /// CachedSubmit and CachedResolve appear under both + /// and + /// because the cached-call lifecycle is channel-agnostic at submit/resolve time. + /// Used by the filter bar to narrow the Kind chip list once Channels are picked. + /// + public static readonly IReadOnlyDictionary> KindsByChannel = + new Dictionary> + { + [AuditChannel.ApiOutbound] = ImmutableList.Create( + AuditKind.ApiCall, AuditKind.ApiCallCached, + AuditKind.CachedSubmit, AuditKind.CachedResolve), + [AuditChannel.DbOutbound] = ImmutableList.Create( + AuditKind.DbWrite, AuditKind.DbWriteCached, + AuditKind.CachedSubmit, AuditKind.CachedResolve), + [AuditChannel.Notification] = ImmutableList.Create( + AuditKind.NotifySend, AuditKind.NotifyDeliver), + [AuditChannel.ApiInbound] = ImmutableList.Create( + AuditKind.InboundRequest, AuditKind.InboundAuthFailure), + }; + + /// + /// Returns the kinds visible in the Kind chip list given the currently selected + /// Channels. With no Channel selected, all 10 kinds are visible (no narrowing). + /// With one or more Channels selected, the union of the channel-specific kind + /// lists is returned (deduplicated and order-stable on first-seen). + /// + public IReadOnlyList VisibleKinds() + { + if (Channels.Count == 0) + { + return Enum.GetValues(); + } + + var seen = new HashSet(); + var result = new List(); + foreach (var ch in Channels) + { + if (!KindsByChannel.TryGetValue(ch, out var kinds)) + { + continue; + } + foreach (var k in kinds) + { + if (seen.Add(k)) + { + result.Add(k); + } + } + } + return result; + } + + /// + /// Collapses this UI model to the repository's single-value filter. + /// See class doc for the multi-select → single-value contract. + /// + public AuditLogQueryFilter ToFilter(DateTime utcNow) + { + var status = ResolveStatus(); + + var (fromUtc, toUtc) = ResolveTimeWindow(utcNow); + + return new AuditLogQueryFilter( + Channel: Channels.Count > 0 ? Channels.First() : null, + Kind: Kinds.Count > 0 ? Kinds.First() : null, + Status: status, + SourceSiteId: SiteIdentifiers.Count > 0 ? SiteIdentifiers.First() : null, + Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(), + Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(), + CorrelationId: null, + FromUtc: fromUtc, + ToUtc: toUtc); + } + + private AuditStatus? ResolveStatus() + { + if (Statuses.Count > 0) + { + // Explicit chips win — Errors-only is a no-op. + return Statuses.First(); + } + + if (ErrorsOnly) + { + // Single-value filter contract: Failed is the lead non-success status. + // When the filter widens to multi-value the full {Failed, Parked, Discarded} + // set will flow through. + return AuditStatus.Failed; + } + + return null; + } + + private (DateTime? From, DateTime? To) ResolveTimeWindow(DateTime utcNow) + { + return TimeRange switch + { + AuditTimeRangePreset.Last5Minutes => (utcNow.AddMinutes(-5), null), + AuditTimeRangePreset.LastHour => (utcNow.AddHours(-1), null), + AuditTimeRangePreset.Last24Hours => (utcNow.AddHours(-24), null), + AuditTimeRangePreset.Custom => (CustomFromUtc, CustomToUtc), + _ => (null, null), + }; + } +} + +/// +/// Time-range presets surfaced in the filter bar. reveals the +/// FromUtc / ToUtc datetime pickers; the other presets compute From relative to +/// "now" at the moment Apply is clicked. +/// +public enum AuditTimeRangePreset +{ + Last5Minutes, + LastHour, + Last24Hours, + Custom, +} diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs new file mode 100644 index 0000000..282c49c --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs @@ -0,0 +1,149 @@ +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ScadaLink.CentralUI.Components.Audit; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Tests.Components.Audit; + +/// +/// bUnit tests for (#23 M7-T2 / Bundle B). +/// +/// The bar carries the 10 spec filter elements plus the Errors-only toggle. Tests +/// pin: (1) the full filter set renders; (2) Apply raises OnFilterChanged +/// with collapsed values; (3) the Channel→Kind narrowing map drives Kind chip +/// visibility; (4) the Errors-only toggle ORs Failed into Status when +/// Status is otherwise empty; (5) the "Last hour" preset populates +/// FromUtc to roughly an hour before "now" — proves the time-window +/// collapse without freezing the clock. +/// +public class AuditFilterBarTests : BunitContext +{ + private readonly ISiteRepository _siteRepo; + + public AuditFilterBarTests() + { + _siteRepo = Substitute.For(); + _siteRepo.GetAllSitesAsync(Arg.Any()) + .Returns(Task.FromResult>(new List + { + new("Plant A", "plant-a") { Id = 1 }, + new("Plant B", "plant-b") { Id = 2 }, + })); + Services.AddSingleton(_siteRepo); + } + + [Fact] + public void Render_AllTenElements_Plus_ErrorsOnlyToggle_Present() + { + var cut = Render(); + + // Each filter element is tagged with a stable data-test attribute so the test + // doesn't churn on cosmetic label changes. + var markers = new[] + { + "data-test=\"filter-channel\"", + "data-test=\"filter-kind\"", + "data-test=\"filter-status\"", + "data-test=\"filter-site\"", + "data-test=\"filter-time-range\"", + "data-test=\"filter-custom-range\"", + "data-test=\"filter-instance\"", + "data-test=\"filter-script\"", + "data-test=\"filter-target\"", + "data-test=\"filter-actor\"", + "data-test=\"filter-errors-only\"", + }; + foreach (var marker in markers) + { + Assert.Contains(marker, cut.Markup); + } + } + + [Fact] + public void Apply_RaisesOnFilterChanged_WithSelectedFilters() + { + AuditLogQueryFilter? captured = null; + var cut = Render(p => p + .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f))); + + // Drive UI: toggle a Channel chip, type in the Target search box, click Apply. + cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click(); + cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC"); + cut.Find("[data-test=\"filter-apply\"]").Click(); + + Assert.NotNull(captured); + Assert.Equal(AuditChannel.ApiOutbound, captured!.Channel); + Assert.Equal("Plant-A-OPC", captured.Target); + } + + [Fact] + public void Channel_Narrows_Kind_Options_When_Selected() + { + var cut = Render(); + + // With no Channel selected, every kind chip is in the DOM. + foreach (var kind in Enum.GetValues()) + { + Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup); + } + + // Select only ApiOutbound; Kind chips outside the channel-kind map drop out. + cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click(); + + var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound]; + foreach (var kind in apiKinds) + { + Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup); + } + // Sanity: an unrelated kind is gone. + Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.NotifySend}\"", cut.Markup); + Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.InboundRequest}\"", cut.Markup); + } + + [Fact] + public void ErrorsOnly_Toggle_Adds_FailedParkedDiscarded_ToStatus_WhenStatusIsEmpty() + { + AuditLogQueryFilter? captured = null; + var cut = Render(p => p + .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f))); + + // Toggle Errors-only ON, leaving Status chips empty. + cut.Find("[data-test=\"filter-errors-only\"] input").Change(true); + cut.Find("[data-test=\"filter-apply\"]").Click(); + + Assert.NotNull(captured); + // Single-value filter contract: Failed leads the non-success set. + Assert.Equal(AuditStatus.Failed, captured!.Status); + + // Now pin an explicit Status chip — Errors-only must yield (chip wins). + cut.Find("[data-test=\"chip-status-Delivered\"]").Click(); + cut.Find("[data-test=\"filter-apply\"]").Click(); + + Assert.Equal(AuditStatus.Delivered, captured!.Status); + } + + [Fact] + public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo() + { + AuditLogQueryFilter? captured = null; + var cut = Render(p => p + .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f))); + + // LastHour is the default preset; clicking Apply must collapse it to FromUtc. + var before = DateTime.UtcNow; + cut.Find("[data-test=\"filter-apply\"]").Click(); + var after = DateTime.UtcNow; + + Assert.NotNull(captured); + Assert.NotNull(captured!.FromUtc); + // FromUtc should be in [now-1h-eps, now-1h+eps] computed against the Apply moment. + var expectedLow = before.AddHours(-1).AddSeconds(-1); + var expectedHigh = after.AddHours(-1).AddSeconds(1); + Assert.InRange(captured.FromUtc!.Value, expectedLow, expectedHigh); + } +} From e052aa4ff8ddc92683916a829489df95c0d324f5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 20:02:46 -0400 Subject: [PATCH 05/14] feat(ui): AuditResultsGrid + AuditLogQueryService with keyset paging (#23 M7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the results grid + query facade for the central Audit Log page (#23 M7-T3): * IAuditLogQueryService / AuditLogQueryService — CentralUI facade over IAuditLogRepository.QueryAsync so the grid can be tested with a stubbed query source. Default page size is 100; callers can override per call. * AuditResultsGrid.razor + .razor.cs — Blazor Server component (Bootstrap only, no third-party UI libs). Renders the 10 columns from Component-AuditLog.md §10 (OccurredAtUtc, Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus, ErrorMessage). Keyset-paged via the last visible row's (OccurredAtUtc, EventId) as the cursor; Next-page button disabled when the current page is short (no count query). Row clicks emit OnRowSelected(AuditEvent) for Bundle C's drilldown drawer. Status badges are colour-coded (Delivered=green; Failed/Parked/Discarded =red; other=gray). Error messages truncated to 80 chars with full text on hover. * Column model framework: a ColumnOrder [Parameter] reorders columns by stable string keys; unknown keys are dropped. M7 scope decision (in the class doc): the framework is in place but drag-reorder / resize UX is not implemented — M7.x can add persisted-per-user reordering without rewriting the column model. * AuditLogPage wired: hosts AuditFilterBar + AuditResultsGrid, threads the filter through and stubs OnRowSelected for Bundle C. * AuditLogQueryService registered as scoped in AddCentralUI. * Tests: 4 grid bUnit tests (10 columns rendered, next-page cursor carries last row, row click raises callback, badge classes for Failed vs Delivered), 2 service tests (filter+paging pass-through, default page size of 100). AuditLogPageScaffoldTests updated to provide the new ISiteRepository + IAuditLogQueryService stubs the page now resolves. --- .../Components/Audit/AuditResultsGrid.razor | 111 ++++++++++ .../Audit/AuditResultsGrid.razor.cs | 199 ++++++++++++++++++ .../Components/Pages/Audit/AuditLogPage.razor | 13 +- .../Pages/Audit/AuditLogPage.razor.cs | 30 ++- .../ServiceCollectionExtensions.cs | 5 + .../Services/AuditLogQueryService.cs | 32 +++ .../Services/IAuditLogQueryService.cs | 30 +++ .../Components/Audit/AuditResultsGridTests.cs | 134 ++++++++++++ .../Pages/AuditLogPageScaffoldTests.cs | 7 + .../Services/AuditLogQueryServiceTests.cs | 57 +++++ 10 files changed, 611 insertions(+), 7 deletions(-) create mode 100644 src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor create mode 100644 src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs create mode 100644 src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs create mode 100644 src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor new file mode 100644 index 0000000..df000aa --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor @@ -0,0 +1,111 @@ +@using ScadaLink.CentralUI.Components.Shared +@using ScadaLink.CentralUI.Services +@using ScadaLink.Commons.Entities.Audit +@using ScadaLink.Commons.Types.Audit +@using ScadaLink.Commons.Types.Enums +@inject IAuditLogQueryService QueryService + +
+ @if (_error is not null) + { +
@_error
+ } + +
+ + + + @foreach (var col in OrderedColumns()) + { + + } + + + + @if (_rows.Count == 0) + { + + + + } + else + { + @foreach (var row in _rows) + { + + @foreach (var col in OrderedColumns()) + { + + } + + } + } + +
@col.Label
+ @if (_loading) + { + Loading… + } + else + { + No audit events match the current filter. + } +
+ @RenderCell(col.Key, row) +
+
+ +
+ Page @_pageNumber · @_rows.Count rows + +
+
+ +@code { + private RenderFragment RenderCell(string key, AuditEvent row) => __builder => + { + switch (key) + { + case "OccurredAtUtc": + var occurredOffset = new DateTimeOffset(DateTime.SpecifyKind(row.OccurredAtUtc, DateTimeKind.Utc)); + + + + break; + case "Site": + @(row.SourceSiteId ?? "—") + break; + case "Channel": + @row.Channel + break; + case "Kind": + @row.Kind + break; + case "Status": + @row.Status + break; + case "Target": + @(row.Target ?? "—") + break; + case "Actor": + @(row.Actor ?? "—") + break; + case "DurationMs": + @(row.DurationMs?.ToString() ?? "—") + break; + case "HttpStatus": + @(row.HttpStatus?.ToString() ?? "—") + break; + case "ErrorMessage": + @TruncateError(row.ErrorMessage) + break; + } + }; +} diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs new file mode 100644 index 0000000..cfbae61 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs @@ -0,0 +1,199 @@ +using Microsoft.AspNetCore.Components; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Components.Audit; + +/// +/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3). +/// Renders the 10 columns named in Component-AuditLog.md §10: +/// OccurredAtUtc, Site, Channel, Kind, Status, Target, Actor, DurationMs, +/// HttpStatus, ErrorMessage. Talks to +/// — never to IAuditLogRepository directly — so tests can stub the data +/// source without standing up EF Core. +/// +/// +/// Column model. Each column has a stable string key; the visible order +/// is the parameter. M7 scope: the column-model +/// framework is in place but resize / drag-reorder UX is intentionally NOT +/// implemented — the full spec calls for persisted-per-user reordering and +/// resizing, which M7.x can ship without rewriting the column model. Resizing +/// today is CSS-based via Bootstrap's .table-responsive wrapper. +/// +/// +/// +/// Pagination. Each page is a single call to +/// IAuditLogQueryService.QueryAsync. The "Next page" button uses the +/// LAST row of the current page as the keyset cursor — repository orders by +/// (OccurredAtUtc DESC, EventId DESC), so the oldest row in the visible +/// page becomes AfterOccurredAtUtc + AfterEventId on the next +/// request. The button is disabled when the current page is short (less than +/// rows) — that's the conventional "we've reached the +/// end" signal for keyset paging without a count query. +/// +/// +public partial class AuditResultsGrid +{ + private const int DefaultPageSize = 100; + + private readonly List _rows = new(); + private int _pageNumber = 1; + private bool _loading; + private string? _error; + + private AuditLogQueryFilter? _activeFilter; + + /// + /// Filter to apply. When this parameter changes the grid resets to page 1 and + /// reissues the query — that's the contract the parent page relies on so the + /// filter-bar Apply button does not need to drive grid state manually. + /// + [Parameter] public AuditLogQueryFilter? Filter { get; set; } + + /// Page size. Defaults to 100 to match the service-level default. + [Parameter] public int PageSize { get; set; } = DefaultPageSize; + + /// + /// Optional column order — list of column keys in display order. When null or + /// empty the default order from Component-AuditLog.md §10 is used. The grid + /// silently drops unknown keys. + /// + [Parameter] public IReadOnlyList? ColumnOrder { get; set; } + + /// + /// Raised when the user clicks a row. Bundle C wires this to the drilldown + /// drawer. The event payload is the full . + /// + [Parameter] public EventCallback OnRowSelected { get; set; } + + // Effective page size used when paging. Mirrors PageSize but bounded > 0. + private int _pageSize => Math.Max(1, PageSize); + + /// + /// Default column definitions. The key is the stable identifier (used by + /// data-test + the column-order parameter); the label is the user-facing + /// header text. Mirrors Component-AuditLog.md §10. + /// + private static readonly IReadOnlyList<(string Key, string Label)> AllColumns = new[] + { + ("OccurredAtUtc", "OccurredAtUtc"), + ("Site", "Site"), + ("Channel", "Channel"), + ("Kind", "Kind"), + ("Status", "Status"), + ("Target", "Target"), + ("Actor", "Actor"), + ("DurationMs", "DurationMs"), + ("HttpStatus", "HttpStatus"), + ("ErrorMessage", "ErrorMessage"), + }; + + private IReadOnlyList<(string Key, string Label)> OrderedColumns() + { + if (ColumnOrder is null || ColumnOrder.Count == 0) + { + return AllColumns; + } + + var byKey = AllColumns.ToDictionary(c => c.Key, c => c); + var ordered = new List<(string Key, string Label)>(ColumnOrder.Count); + foreach (var key in ColumnOrder) + { + if (byKey.TryGetValue(key, out var col)) + { + ordered.Add(col); + } + } + return ordered.Count == 0 ? AllColumns : ordered; + } + + protected override async Task OnParametersSetAsync() + { + // Reset & reload whenever the filter reference changes. AuditLogQueryFilter + // is a record, so equality-by-value gives us a free "did the user click Apply + // with the same chips?" no-op signal. We pin to ReferenceEquals here so the + // grid reloads only when the parent hands us a new filter instance — the + // page wraps Apply in a fresh allocation, which is the canonical reload signal. + if (!ReferenceEquals(_activeFilter, Filter)) + { + _activeFilter = Filter; + _pageNumber = 1; + _rows.Clear(); + if (Filter is not null) + { + await LoadAsync(paging: null); + } + } + } + + private async Task NextPage() + { + if (_rows.Count == 0 || _activeFilter is null) + { + return; + } + + var last = _rows[^1]; + var cursor = new AuditLogPaging( + PageSize: _pageSize, + AfterOccurredAtUtc: last.OccurredAtUtc, + AfterEventId: last.EventId); + + await LoadAsync(cursor); + _pageNumber++; + } + + private async Task LoadAsync(AuditLogPaging? paging) + { + if (_activeFilter is null) + { + return; + } + + _loading = true; + _error = null; + try + { + var effective = paging ?? new AuditLogPaging(_pageSize); + var page = await QueryService.QueryAsync(_activeFilter, effective); + _rows.Clear(); + _rows.AddRange(page); + } + catch (Exception ex) + { + // Surface the error in-place; the grid stays alive so the user can + // adjust the filter and retry without a page refresh. + _error = $"Query failed: {ex.Message}"; + } + finally + { + _loading = false; + } + } + + private async Task HandleRowClick(AuditEvent row) + { + if (OnRowSelected.HasDelegate) + { + await OnRowSelected.InvokeAsync(row); + } + } + + private static string StatusBadgeClass(AuditStatus status) => status switch + { + AuditStatus.Delivered => "badge bg-success", + AuditStatus.Failed or AuditStatus.Parked or AuditStatus.Discarded => "badge bg-danger", + _ => "badge bg-secondary", + }; + + private static string TruncateError(string? message) + { + if (string.IsNullOrEmpty(message)) + { + return "—"; + } + const int max = 80; + return message.Length <= max ? message : string.Concat(message.AsSpan(0, max), "…"); + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor index 8d250e7..0b759f2 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor @@ -1,16 +1,25 @@ @page "/audit/log" @attribute [Authorize] +@using ScadaLink.CentralUI.Components.Audit +@using ScadaLink.CentralUI.Services +@using ScadaLink.Commons.Entities.Audit +@using ScadaLink.Commons.Types.Audit +@inject IAuditLogQueryService AuditLogQueryService Audit Log

Audit Log

- @* AuditFilterBar will go here (Bundle B). *@ + @* Filter bar (Bundle B / M7-T2). Apply hands the collapsed filter to the grid. *@
+
- @* AuditResultsGrid will go here (Bundle B). *@ + @* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's + drilldown drawer; the grid stays in "no events" mode until the user applies a + filter so the page does not auto-load the full audit table on first render. *@
+
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs index 311f369..6e80600 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs @@ -1,12 +1,32 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Audit; + namespace ScadaLink.CentralUI.Components.Pages.Audit; /// -/// Code-behind for the central Audit Log page (#23 M7-T1). The Bundle A -/// scaffold has no behaviour — the filter bar and results grid arrive in -/// Bundle B (M7-T2..M7-T7). Keeping the partial class in place now lets -/// later bundles add injected services and event handlers without -/// touching the route or page-title markup. +/// Code-behind for the central Audit Log page (#23 M7). Bundle B (M7-T2 + M7-T3) +/// wires up AuditFilterBar and AuditResultsGrid: the page owns the +/// active and re-pushes a fresh instance to the +/// grid on every Apply (the grid uses reference identity as its "reload" +/// trigger). Row clicks land in — Bundle C wires +/// this to the drilldown drawer; for now it is a no-op seam so test stubs do +/// not error. /// public partial class AuditLogPage { + private AuditLogQueryFilter? _currentFilter; + + private void HandleFilterChanged(AuditLogQueryFilter filter) + { + // Always reassign — the grid keys reloads on reference change, so even a + // chip-for-chip identical filter must allocate a fresh instance. + _currentFilter = filter; + } + + private void HandleRowSelected(AuditEvent row) + { + // Reserved for Bundle C (drilldown drawer). Intentionally left empty: the + // grid still raises the event, but we do nothing with it yet. + _ = row; + } } diff --git a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs index 0caf98c..be2310b 100644 --- a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using ScadaLink.CentralUI.Auth; using ScadaLink.CentralUI.Components.Shared; using ScadaLink.CentralUI.ScriptAnalysis; +using ScadaLink.CentralUI.Services; namespace ScadaLink.CentralUI; @@ -27,6 +28,10 @@ public static class ServiceCollectionExtensions // Components/Shared/IDialogService.cs. services.AddScoped(); + // Audit Log (#23 M7-T3): CentralUI facade over IAuditLogRepository so the + // results grid can be tested with a stubbed query source. + services.AddScoped(); + // Roslyn-backed C# analysis for the Monaco script editor. // Scoped because SharedScriptCatalog wraps a scoped service. services.AddMemoryCache(o => o.SizeLimit = 200); diff --git a/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs new file mode 100644 index 0000000..971960e --- /dev/null +++ b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs @@ -0,0 +1,32 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Types.Audit; + +namespace ScadaLink.CentralUI.Services; + +/// +/// Default implementation — a thin pass-through +/// to . Default page size is 100 (the +/// AuditResultsGrid default for #23 M7). +/// +public sealed class AuditLogQueryService : IAuditLogQueryService +{ + private readonly IAuditLogRepository _repository; + + public AuditLogQueryService(IAuditLogRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public int DefaultPageSize => 100; + + public Task> QueryAsync( + AuditLogQueryFilter filter, + AuditLogPaging? paging = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(filter); + var effective = paging ?? new AuditLogPaging(DefaultPageSize); + return _repository.QueryAsync(filter, effective, ct); + } +} diff --git a/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs new file mode 100644 index 0000000..b9236f9 --- /dev/null +++ b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs @@ -0,0 +1,30 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Audit; + +namespace ScadaLink.CentralUI.Services; + +/// +/// CentralUI facade over +/// (#23 M7-T3). The Audit Log page's results grid talks to this service rather than +/// the repository directly so tests can substitute a fake without spinning up EF +/// Core, and so a future caching / shaping layer (e.g. server-side CSV streaming) +/// can hang off the same seam. +/// +public interface IAuditLogQueryService +{ + /// + /// Returns a keyset-paged result page for . When + /// is null, defaults to + /// rows with no cursor (first page). The repository orders by + /// (OccurredAtUtc DESC, EventId DESC); pass the last row's + /// + + /// back as the cursor for the next page. + /// + Task> QueryAsync( + AuditLogQueryFilter filter, + AuditLogPaging? paging = null, + CancellationToken ct = default); + + /// Default page size when callers don't specify one. + int DefaultPageSize { get; } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs new file mode 100644 index 0000000..fa8fdec --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs @@ -0,0 +1,134 @@ +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ScadaLink.CentralUI.Components.Audit; +using ScadaLink.CentralUI.Services; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Tests.Components.Audit; + +/// +/// bUnit tests for (#23 M7-T3 / Bundle B). The grid +/// renders 10 columns, paginates via keyset (passing the last row's +/// (OccurredAtUtc, EventId) back to the service), raises a row-click callback +/// that Bundle C wires to the drilldown drawer, and styles non-success status +/// rows with an error-coded badge. +/// +public class AuditResultsGridTests : BunitContext +{ + private readonly IAuditLogQueryService _service; + private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new(); + + private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a") + => new() + { + EventId = Guid.NewGuid(), + OccurredAtUtc = occurredAtUtc, + Channel = channel, + Kind = kind, + Status = status, + SourceSiteId = site, + Target = "demo-target", + Actor = "tester", + DurationMs = 42, + HttpStatus = status == AuditStatus.Delivered ? 200 : 500, + ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null, + }; + + public AuditResultsGridTests() + { + _service = Substitute.For(); + _service.DefaultPageSize.Returns(100); + Services.AddSingleton(_service); + } + + private void StubPage(IReadOnlyList rows) + { + _service.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + _calls.Add(((AuditLogQueryFilter)callInfo[0], (AuditLogPaging?)callInfo[1])); + return Task.FromResult(rows); + }); + } + + [Fact] + public void Render_TenColumns_FromStubService() + { + StubPage(new List + { + MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered), + }); + + var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + // 10 column headers per Component-AuditLog.md §10. + var expectedHeaders = new[] + { + "OccurredAtUtc", "Site", "Channel", "Kind", "Status", + "Target", "Actor", "DurationMs", "HttpStatus", "ErrorMessage", + }; + foreach (var header in expectedHeaders) + { + Assert.Contains($"data-test=\"col-header-{header}\"", cut.Markup); + } + } + + [Fact] + public void Click_NextPage_CallsService_WithCursor_OfLastRow() + { + // First page: two rows, descending by OccurredAtUtc. The grid must pass the + // LAST row (the older one) back as the keyset cursor for the next page. + var first = MakeEvent(new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), AuditStatus.Delivered); + var second = MakeEvent(new DateTime(2026, 5, 20, 11, 30, 0, DateTimeKind.Utc), AuditStatus.Failed); + StubPage(new[] { first, second }); + + var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + cut.Find("[data-test=\"grid-next-page\"]").Click(); + + // Two service calls: initial + next. + Assert.Equal(2, _calls.Count); + var nextCall = _calls[1]; + Assert.NotNull(nextCall.Paging); + Assert.Equal(second.OccurredAtUtc, nextCall.Paging!.AfterOccurredAtUtc); + Assert.Equal(second.EventId, nextCall.Paging.AfterEventId); + } + + [Fact] + public void Click_Row_RaisesOnRowSelected() + { + var target = MakeEvent(DateTime.UtcNow.AddMinutes(-5), AuditStatus.Delivered); + StubPage(new[] { target }); + + AuditEvent? captured = null; + var cut = Render(p => p + .Add(c => c.Filter, new AuditLogQueryFilter()) + .Add(c => c.OnRowSelected, EventCallback.Factory.Create(this, e => captured = e))); + + cut.Find($"[data-test=\"grid-row-{target.EventId}\"]").Click(); + + Assert.NotNull(captured); + Assert.Equal(target.EventId, captured!.EventId); + } + + [Fact] + public void Status_FailedRow_HasErrorBadgeClass() + { + var failed = MakeEvent(DateTime.UtcNow.AddMinutes(-2), AuditStatus.Failed); + var delivered = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered); + StubPage(new[] { delivered, failed }); + + var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + // Failed badge => bg-danger (red). Delivered => bg-success (green). + var failedBadge = cut.Find($"[data-test=\"status-badge-{failed.EventId}\"]"); + Assert.Contains("bg-danger", failedBadge.GetAttribute("class") ?? string.Empty); + + var deliveredBadge = cut.Find($"[data-test=\"status-badge-{delivered.EventId}\"]"); + Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs index 3ab4892..6d5c07b 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs @@ -4,6 +4,8 @@ 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.Security; using AuditLogPage = ScadaLink.CentralUI.Components.Pages.Audit.AuditLogPage; using NavMenu = ScadaLink.CentralUI.Components.Layout.NavMenu; @@ -36,6 +38,11 @@ public class AuditLogPageScaffoldTests : BunitContext Services.AddAuthorizationCore(); AuthorizationPolicies.AddScadaLinkAuthorization(Services); Services.AddSingleton(); + // The page now hosts AuditFilterBar + AuditResultsGrid which depend on + // ISiteRepository and IAuditLogQueryService respectively (Bundle B). + // Provide stand-ins so the scaffold smoke tests still render the page. + Services.AddSingleton(Substitute.For()); + Services.AddSingleton(Substitute.For()); return Render(); } diff --git a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs new file mode 100644 index 0000000..97743bf --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs @@ -0,0 +1,57 @@ +using NSubstitute; +using ScadaLink.CentralUI.Services; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Tests.Services; + +/// +/// Service-level tests for (#23 M7-T3). The +/// service is a thin pass-through over ; +/// these tests pin the filter forwarding contract and the 100-row default-page-size +/// rule the grid relies on. +/// +public class AuditLogQueryServiceTests +{ + [Fact] + public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository() + { + var repo = Substitute.For(); + var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound); + var paging = new AuditLogPaging(PageSize: 25); + var expected = new List + { + new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered } + }; + repo.QueryAsync(filter, paging, Arg.Any()) + .Returns(Task.FromResult>(expected)); + + var sut = new AuditLogQueryService(repo); + + var result = await sut.QueryAsync(filter, paging); + + Assert.Same(expected, result); + await repo.Received(1).QueryAsync(filter, paging, Arg.Any()); + } + + [Fact] + public async Task QueryAsync_AppliesDefaultPageSize_WhenNotSpecified() + { + var repo = Substitute.For(); + AuditLogPaging? observed = null; + repo.QueryAsync(Arg.Any(), Arg.Do(p => observed = p), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + + var sut = new AuditLogQueryService(repo); + + await sut.QueryAsync(new AuditLogQueryFilter(), paging: null); + + Assert.NotNull(observed); + Assert.Equal(sut.DefaultPageSize, observed!.PageSize); + Assert.Equal(100, sut.DefaultPageSize); + Assert.Null(observed.AfterOccurredAtUtc); + Assert.Null(observed.AfterEventId); + } +} From ae4480e7aad23efebf05815e1a8d537f67900151 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 20:13:33 -0400 Subject: [PATCH 06/14] feat(ui): AuditDrilldownDrawer with JSON/SQL render, cURL, drill-back, redaction badges (#23 M7) Implements Bundle C (M7-T4 through M7-T8) of the Audit Log #23 M7 Central UI work: a right-side off-canvas drawer that opens from AuditResultsGrid row clicks and renders one AuditEvent in full. Cohesive single-component delivery: - Read-only fields stacked (form-layout memory): Channel/Kind, Status, HttpStatus, Target, Actor, Source* provenance, CorrelationId, OccurredAtUtc, IngestedAtUtc, DurationMs. - Channel-aware body renderer: DbOutbound {sql, parameters} payloads render a code-block with CSS-only .language-sql class plus a parameter
; other channels JSON-pretty-print when parseable and fall back to verbatim
.
- Redaction badges on Request/Response when the body contains the
   or  sentinels.
- Copy-as-cURL (API channels only) builds a curl command from Target
  + optional {method, headers, body} RequestSummary JSON and writes
  it via navigator.clipboard.writeText.
- Show-all-events drill-back navigates to /audit/log?correlationId={id}
  when the event carries a CorrelationId.
- Close button + backdrop-click both raise OnClose.

AuditLogPage wires Event/IsOpen/OnClose; row clicks now open the
drawer (HandleRowSelected pins _selectedEvent + _drawerOpen=true).

11 bUnit tests cover field rendering, JSON pretty-print, verbatim
fallback, SQL block, conditional buttons, redaction badges,
navigation drill-back, and clipboard interop. No third-party UI
libraries: Bootstrap offcanvas + scoped razor.css only.
---
 .../Audit/AuditDrilldownDrawer.razor          | 161 ++++++++
 .../Audit/AuditDrilldownDrawer.razor.cs       | 374 ++++++++++++++++++
 .../Audit/AuditDrilldownDrawer.razor.css      |  40 ++
 .../Components/Pages/Audit/AuditLogPage.razor |   6 +
 .../Pages/Audit/AuditLogPage.razor.cs         |  17 +-
 .../Audit/AuditDrilldownDrawerTests.cs        | 252 ++++++++++++
 6 files changed, 847 insertions(+), 3 deletions(-)
 create mode 100644 src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor
 create mode 100644 src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.cs
 create mode 100644 src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.css
 create mode 100644 tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs

diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor
new file mode 100644
index 0000000..f580fb8
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor
@@ -0,0 +1,161 @@
+@using ScadaLink.Commons.Entities.Audit
+@using ScadaLink.Commons.Types.Enums
+
+@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
+   Right-side Bootstrap offcanvas-style drawer hosted by the Audit Log page.
+   All form/field rendering follows the form-layout memory:
+   read-only fields first (definition list), then subsections stacked,
+   action buttons at the bottom of the drawer. *@
+
+@if (IsOpen && Event is not null)
+{
+    
+
+
+
+
Audit event
+
Audit Event @ShortEventId(Event.EventId)
+
+ +
+ +
+ @* Read-only field list — primary identification + provenance. *@ +
+
Channel / Kind
+
@Event.Channel / @Event.Kind
+ +
Status
+
@Event.Status
+ +
HttpStatus
+
@(Event.HttpStatus?.ToString() ?? "—")
+ +
Target
+
@(Event.Target ?? "—")
+ +
Actor
+
@(Event.Actor ?? "—")
+ +
SourceSiteId
+
@(Event.SourceSiteId ?? "—")
+ +
SourceInstanceId
+
@(Event.SourceInstanceId ?? "—")
+ +
SourceScript
+
@(Event.SourceScript ?? "—")
+ +
CorrelationId
+
@(Event.CorrelationId?.ToString() ?? "—")
+ +
OccurredAtUtc
+
@FormatTimestamp(Event.OccurredAtUtc)
+ +
IngestedAtUtc
+
@(Event.IngestedAtUtc.HasValue ? FormatTimestamp(Event.IngestedAtUtc.Value) : "—")
+ +
DurationMs
+
@(Event.DurationMs?.ToString() ?? "—")
+
+ + @* Error subsection — only shown when there is something to report. *@ + @if (!string.IsNullOrEmpty(Event.ErrorMessage) || !string.IsNullOrEmpty(Event.ErrorDetail)) + { +
+
Error
+ @if (!string.IsNullOrEmpty(Event.ErrorMessage)) + { +

@Event.ErrorMessage

+ } + @if (!string.IsNullOrEmpty(Event.ErrorDetail)) + { +
@Event.ErrorDetail
+ } +
+ } + + @* Request body (channel-aware renderer). *@ + @if (!string.IsNullOrEmpty(Event.RequestSummary)) + { +
+
+ Request + @if (IsRedacted(Event.RequestSummary)) + { + + Redacted + + } +
+
+ @RenderBody(Event.RequestSummary!, Event.Channel) +
+
+ } + + @* Response body (channel-aware renderer). *@ + @if (!string.IsNullOrEmpty(Event.ResponseSummary)) + { +
+
+ Response + @if (IsRedacted(Event.ResponseSummary)) + { + + Redacted + + } +
+
+ @RenderBody(Event.ResponseSummary!, Event.Channel) +
+
+ } + + @* Extra is always JSON when present. *@ + @if (!string.IsNullOrEmpty(Event.Extra)) + { +
+
Extra
+
@PrettyPrintJson(Event.Extra!)
+
+ } +
+ + @* Action buttons at the bottom per form-layout memory. *@ + +
+} diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.cs new file mode 100644 index 0000000..77a2847 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.cs @@ -0,0 +1,374 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Components.Audit; + +/// +/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8). +/// Renders one in a right-side off-canvas drawer: +/// read-only fields, conditional Error/Request/Response/Extra subsections, +/// and action buttons (Copy as cURL, Show all events for this operation, +/// Close). The drawer is fully presentational — it has no DB or service +/// dependencies; the host page owns the open/close state. +/// +/// +/// Body rendering. Request/Response/Extra summaries are strings. +/// The drawer pretty-prints JSON when it parses; falls back to verbatim +/// otherwise. DbOutbound payloads carry a {sql, parameters} JSON +/// shape and get a SQL code block plus a parameter definition list. +/// Syntax highlighting is CSS-class-only (language-sql); no JS +/// library is loaded — Blazor Server + Bootstrap only per the project's UI +/// rules. +/// +/// +/// +/// Redaction badges. The audit pipeline replaces redacted values +/// with the literal sentinels <redacted> or +/// <redacted: redactor error> (see Component-AuditLog.md +/// §Redaction). The drawer surfaces a yellow "Redacted" badge on a body +/// section when its text contains either sentinel — it does not attempt +/// to un-redact or count occurrences. +/// +/// +/// +/// Copy as cURL. Best-effort: the URL comes from Target; +/// when the RequestSummary parses as {headers, body}, headers are +/// folded into -H flags and the body into --data-raw. The +/// command is written to the system clipboard via +/// . We only +/// surface the button for API channels (ApiOutbound / ApiInbound). +/// +/// +/// +/// Drill-back. When is set, +/// the "Show all events" button navigates to +/// /audit/log?correlationId={id}. The parent page does not +/// auto-apply that filter today — it is a deep link the page can use +/// when Bundle D wires up query-string deserialization. +/// +/// +public partial class AuditDrilldownDrawer +{ + [Inject] private IJSRuntime JS { get; set; } = null!; + [Inject] private NavigationManager Navigation { get; set; } = null!; + + /// + /// The row to render. When null the drawer renders nothing — the host + /// page uses this together with to drive visibility. + /// + [Parameter] public AuditEvent? Event { get; set; } + + /// + /// True when the host wants the drawer visible. We deliberately keep + /// this as a separate parameter from : an open + /// drawer briefly with a null event renders nothing (the row may still + /// be loading); a closed drawer with a stale event is the resting state. + /// + [Parameter] public bool IsOpen { get; set; } + + /// + /// Fired when the user dismisses the drawer (close button or backdrop + /// click). The host is expected to flip to false. + /// + [Parameter] public EventCallback OnClose { get; set; } + + private const string RedactionSentinel = ""; + private const string RedactorErrorSentinel = ""; + + private static bool IsApiChannel(AuditChannel channel) + => channel is AuditChannel.ApiOutbound or AuditChannel.ApiInbound; + + private static string ShortEventId(Guid eventId) + { + // Mirror the "first 8 hex digits" presentation common across the UI. + var n = eventId.ToString("N"); + return n.Length >= 8 ? n[..8] : n; + } + + private static string FormatTimestamp(DateTime utc) + { + // Force UTC kind in case the row arrived as Unspecified, then emit + // round-trip ISO-8601 so audit drilldowns are copy-paste safe. + var kind = utc.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(utc, DateTimeKind.Utc) : utc; + return kind.ToString("o", CultureInfo.InvariantCulture); + } + + private static bool IsRedacted(string? text) + { + if (string.IsNullOrEmpty(text)) return false; + return text.Contains(RedactionSentinel, StringComparison.Ordinal) + || text.Contains(RedactorErrorSentinel, StringComparison.Ordinal); + } + + /// + /// Channel-aware body renderer. DbOutbound bodies that parse as + /// {sql, parameters} get a SQL block + parameter list; anything + /// else falls back to JSON-pretty-print, then plain-text verbatim. + /// + private RenderFragment RenderBody(string body, AuditChannel channel) => builder => + { + // DbOutbound special-case: try to extract {sql, parameters}. + if (channel == AuditChannel.DbOutbound && TryParseDbBody(body, out var sql, out var parameters)) + { + builder.OpenElement(0, "pre"); + builder.AddAttribute(1, "class", "bg-light border rounded p-2 mb-2 drawer-pre"); + builder.OpenElement(2, "code"); + // Highlighting is CSS-class-only — no JS library is loaded. + builder.AddAttribute(3, "class", "language-sql"); + builder.AddContent(4, sql); + builder.CloseElement(); + builder.CloseElement(); + + if (parameters is not null && parameters.Count > 0) + { + builder.OpenElement(10, "dl"); + builder.AddAttribute(11, "class", "row mb-0 small"); + builder.AddAttribute(12, "data-test", "sql-parameters"); + // The analyzer (ASP0006) requires literal sequence numbers + // inside a render fragment. We delegate parameter rendering + // to a helper fragment that uses a stable @key per entry, + // so per-row diffing stays correct even though the outer + // sequence number is fixed. + builder.AddContent(13, BuildSqlParameterRows(parameters)); + builder.CloseElement(); + } + return; + } + + // Generic JSON pretty-print path. + if (TryPrettyPrintJson(body, out var pretty)) + { + builder.OpenElement(20, "pre"); + builder.AddAttribute(21, "class", "bg-light border rounded p-2 mb-0 drawer-pre json"); + builder.AddContent(22, pretty); + builder.CloseElement(); + return; + } + + // Fallback: verbatim. Wrapping in
 preserves whitespace, which
+        // is useful when the body is multi-line plain text or a partial JSON.
+        builder.OpenElement(30, "pre");
+        builder.AddAttribute(31, "class", "bg-light border rounded p-2 mb-0 drawer-pre");
+        builder.AddContent(32, body);
+        builder.CloseElement();
+    };
+
+    private static RenderFragment BuildSqlParameterRows(List> parameters) => builder =>
+    {
+        foreach (var kv in parameters)
+        {
+            // Literal sequence numbers (ASP0006) + per-element SetKey so
+            // Blazor's diff is still keyed on parameter name. The "0" base
+            // is fine here — each loop iteration produces a disjoint
+            // dt/dd pair, and the diff keys on @key, not sequence.
+            builder.OpenElement(0, "dt");
+            builder.SetKey($"dt-{kv.Key}");
+            builder.AddAttribute(1, "class", "col-4 text-muted fw-normal font-monospace");
+            builder.AddContent(2, kv.Key);
+            builder.CloseElement();
+
+            builder.OpenElement(3, "dd");
+            builder.SetKey($"dd-{kv.Key}");
+            builder.AddAttribute(4, "class", "col-8 font-monospace");
+            builder.AddContent(5, kv.Value);
+            builder.CloseElement();
+        }
+    };
+
+    private static bool TryPrettyPrintJson(string text, out string formatted)
+    {
+        formatted = text;
+        try
+        {
+            using var doc = JsonDocument.Parse(text);
+            formatted = JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
+            return true;
+        }
+        catch (JsonException)
+        {
+            return false;
+        }
+    }
+
+    private static string PrettyPrintJson(string text)
+        => TryPrettyPrintJson(text, out var pretty) ? pretty : text;
+
+    /// 
+    /// Best-effort parse of a DbOutbound {sql, parameters} body.
+    /// Returns true only when the JSON has a string sql property;
+    /// parameters is treated as an optional object whose values
+    /// stringify to scalar text.
+    /// 
+    private static bool TryParseDbBody(string text, out string sql, out List>? parameters)
+    {
+        sql = string.Empty;
+        parameters = null;
+        try
+        {
+            using var doc = JsonDocument.Parse(text);
+            if (doc.RootElement.ValueKind != JsonValueKind.Object) return false;
+            if (!doc.RootElement.TryGetProperty("sql", out var sqlProp) || sqlProp.ValueKind != JsonValueKind.String)
+                return false;
+            sql = sqlProp.GetString() ?? string.Empty;
+
+            if (doc.RootElement.TryGetProperty("parameters", out var paramsProp)
+                && paramsProp.ValueKind == JsonValueKind.Object)
+            {
+                parameters = new List>();
+                foreach (var p in paramsProp.EnumerateObject())
+                {
+                    parameters.Add(new KeyValuePair(p.Name, StringifyJsonValue(p.Value)));
+                }
+            }
+            return true;
+        }
+        catch (JsonException)
+        {
+            return false;
+        }
+    }
+
+    private static string StringifyJsonValue(JsonElement value) => value.ValueKind switch
+    {
+        JsonValueKind.String => value.GetString() ?? string.Empty,
+        JsonValueKind.Null => "null",
+        JsonValueKind.True => "true",
+        JsonValueKind.False => "false",
+        JsonValueKind.Number => value.GetRawText(),
+        _ => value.GetRawText(),
+    };
+
+    private async Task HandleClose()
+    {
+        if (OnClose.HasDelegate)
+        {
+            await OnClose.InvokeAsync();
+        }
+    }
+
+    private async Task CopyCurl()
+    {
+        if (Event is null) return;
+
+        var curl = BuildCurlCommand(Event);
+        try
+        {
+            await JS.InvokeVoidAsync("navigator.clipboard.writeText", curl);
+        }
+        catch
+        {
+            // Clipboard interop can fail (denied permission, prerender, etc.).
+            // The drawer stays open; the failure surfaces in the dev console
+            // only — we deliberately do not toast here because the parent
+            // page owns toast state.
+        }
+    }
+
+    private void ShowAllForOperation()
+    {
+        if (Event?.CorrelationId is not { } corr) return;
+        var uri = $"/audit/log?correlationId={corr}";
+        Navigation.NavigateTo(uri);
+    }
+
+    /// 
+    /// Build a cURL command from an audit event. The URL comes from
+    /// Target; when the RequestSummary parses as
+    /// {headers, body, method?}, headers fold into -H flags
+    /// and the body into --data-raw. Default method is POST for
+    /// outbound audit rows — the audit pipeline does not always capture
+    /// the verb explicitly.
+    /// 
+    private static string BuildCurlCommand(AuditEvent ev)
+    {
+        var sb = new StringBuilder();
+        sb.Append("curl");
+
+        string method = "POST";
+        List>? headers = null;
+        string? body = null;
+
+        if (!string.IsNullOrEmpty(ev.RequestSummary))
+        {
+            TryExtractCurlPartsFromJson(ev.RequestSummary!, ref method, ref headers, ref body);
+        }
+
+        sb.Append(' ').Append("-X ").Append(method);
+
+        if (headers is not null)
+        {
+            foreach (var (name, value) in headers)
+            {
+                sb.Append(' ').Append("-H ");
+                sb.Append(QuoteShellArg($"{name}: {value}"));
+            }
+        }
+
+        if (!string.IsNullOrEmpty(body))
+        {
+            sb.Append(' ').Append("--data-raw ");
+            sb.Append(QuoteShellArg(body!));
+        }
+
+        var url = ev.Target ?? string.Empty;
+        sb.Append(' ').Append(QuoteShellArg(url));
+        return sb.ToString();
+    }
+
+    private static void TryExtractCurlPartsFromJson(
+        string requestSummary,
+        ref string method,
+        ref List>? headers,
+        ref string? body)
+    {
+        try
+        {
+            using var doc = JsonDocument.Parse(requestSummary);
+            if (doc.RootElement.ValueKind != JsonValueKind.Object) return;
+
+            if (doc.RootElement.TryGetProperty("method", out var m) && m.ValueKind == JsonValueKind.String)
+            {
+                method = m.GetString() ?? method;
+            }
+            if (doc.RootElement.TryGetProperty("headers", out var hs) && hs.ValueKind == JsonValueKind.Object)
+            {
+                headers = new List>();
+                foreach (var h in hs.EnumerateObject())
+                {
+                    var value = h.Value.ValueKind == JsonValueKind.String
+                        ? h.Value.GetString() ?? string.Empty
+                        : h.Value.GetRawText();
+                    headers.Add(new KeyValuePair(h.Name, value));
+                }
+            }
+            if (doc.RootElement.TryGetProperty("body", out var b))
+            {
+                body = b.ValueKind == JsonValueKind.String
+                    ? b.GetString()
+                    : b.GetRawText();
+            }
+        }
+        catch (JsonException)
+        {
+            // RequestSummary wasn't the expected {headers, body} shape —
+            // just produce a bare cURL with no body/headers.
+        }
+    }
+
+    /// 
+    /// Quote a single shell argument with single quotes, escaping embedded
+    /// single quotes via the standard '\'' idiom. This is the same
+    /// quoting strategy curl examples use across man pages.
+    /// 
+    private static string QuoteShellArg(string value)
+    {
+        if (string.IsNullOrEmpty(value)) return "''";
+        var escaped = value.Replace("'", "'\\''", StringComparison.Ordinal);
+        return $"'{escaped}'";
+    }
+}
diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.css b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.css
new file mode 100644
index 0000000..58133ba
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.css
@@ -0,0 +1,40 @@
+/* Audit Log drilldown drawer (#23 M7 Bundle C).
+   The base offcanvas + backdrop classes come from Bootstrap. The local
+   overrides below pin our preferred width and pre-block behaviour. */
+
+.audit-drilldown-drawer {
+    /* Slightly wider than the parked-messages drawer because audit rows can
+       carry larger JSON bodies and SQL blocks. Clamp to viewport so narrow
+       windows still get the close button on screen. */
+    width: min(720px, 95vw);
+}
+
+.audit-drilldown-drawer .drawer-pre {
+    /* Wrap long lines and bound the per-block height so the drawer body
+       stays scrollable end-to-end instead of pushing the action buttons
+       below the fold. */
+    white-space: pre-wrap;
+    word-break: break-word;
+    max-height: 320px;
+    overflow-y: auto;
+    margin: 0;
+    font-size: 0.8125rem;
+}
+
+.audit-drilldown-drawer .drawer-pre.json {
+    /* JSON blocks get a faint left rule so they read as quoted material. */
+    border-left: 3px solid var(--bs-info-border-subtle);
+}
+
+.audit-drilldown-drawer .drawer-pre code.language-sql {
+    /* CSS-only highlight cue: SQL stays mono with a hint of bold weight on
+       a slightly different background so the SQL block reads distinct from
+       generic JSON pretty-prints without loading a syntax-highlighter JS
+       library. */
+    font-family: var(--bs-font-monospace);
+    color: var(--bs-emphasis-color);
+}
+
+.audit-drilldown-drawer .drawer-footer {
+    background-color: var(--bs-tertiary-bg);
+}
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor
index 0b759f2..f47a807 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor
+++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor
@@ -23,3 +23,9 @@
         
     
+ +@* Drilldown drawer (Bundle C / M7-T4..T8). Hosted at the page level so the + off-canvas overlay sits above the grid / filter bar irrespective of scroll. *@ + diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs index 6e80600..4ecc647 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs @@ -15,6 +15,8 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit; public partial class AuditLogPage { private AuditLogQueryFilter? _currentFilter; + private AuditEvent? _selectedEvent; + private bool _drawerOpen; private void HandleFilterChanged(AuditLogQueryFilter filter) { @@ -25,8 +27,17 @@ public partial class AuditLogPage private void HandleRowSelected(AuditEvent row) { - // Reserved for Bundle C (drilldown drawer). Intentionally left empty: the - // grid still raises the event, but we do nothing with it yet. - _ = row; + // Bundle C: a grid row click hands us the full AuditEvent. We pin it as + // the selected row and open the drilldown drawer — the drawer is fully + // presentational so we do not need to refetch the row. + _selectedEvent = row; + _drawerOpen = true; + } + + private void HandleDrawerClose() + { + // We deliberately keep _selectedEvent set so re-opening (e.g. via the + // grid) shows the same row instantly without a re-render flicker. + _drawerOpen = false; } } diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs new file mode 100644 index 0000000..53c7c2d --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs @@ -0,0 +1,252 @@ +using Bunit; +using Bunit.TestDoubles; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.CentralUI.Components.Audit; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Tests.Components.Audit; + +/// +/// bUnit tests for (#23 M7 Bundle C / M7-T4..T8). +/// +/// The drawer is a child component opened from the Audit Log page when a grid row +/// is clicked. It renders the full read-only, with +/// channel-aware bodies (JSON pretty-print, SQL block for DbOutbound), +/// redaction badges on Request/Response, and conditional action buttons: +/// "Copy as cURL" (API channels only) + "Show all events for this operation" +/// (when CorrelationId is set). +/// +/// Tests pin the behaviours we cannot lose without breaking the spec: +/// field rendering, JSON pretty-printing, SQL render block, conditional button +/// visibility, navigation drill-back, redaction badges, and clipboard interop. +/// +public class AuditDrilldownDrawerTests : BunitContext +{ + public AuditDrilldownDrawerTests() + { + // Default to Loose so the cURL clipboard call does not blow up tests + // that don't exercise it. Tests that need to assert interop calls flip + // to Strict and configure their own setups. + JSInterop.Mode = JSRuntimeMode.Loose; + } + + private static AuditEvent MakeEvent( + AuditChannel channel = AuditChannel.ApiOutbound, + AuditKind kind = AuditKind.ApiCall, + AuditStatus status = AuditStatus.Delivered, + string? requestSummary = null, + string? responseSummary = null, + string? extra = null, + Guid? correlationId = null, + string? errorMessage = null, + string? errorDetail = null, + string? target = "demo-target") + => new() + { + EventId = Guid.Parse("11111111-2222-3333-4444-555555555555"), + OccurredAtUtc = new DateTime(2026, 5, 20, 12, 30, 45, DateTimeKind.Utc), + IngestedAtUtc = new DateTime(2026, 5, 20, 12, 30, 46, DateTimeKind.Utc), + Channel = channel, + Kind = kind, + CorrelationId = correlationId, + SourceSiteId = "plant-a", + SourceInstanceId = "boiler-3", + SourceScript = "OnAlarm.csx", + Actor = "tester", + Target = target, + Status = status, + HttpStatus = status == AuditStatus.Delivered ? 200 : 500, + DurationMs = 42, + ErrorMessage = errorMessage, + ErrorDetail = errorDetail, + RequestSummary = requestSummary, + ResponseSummary = responseSummary, + Extra = extra, + }; + + [Fact] + public void Drawer_RendersField_OccurredAtUtc() + { + var ev = MakeEvent(); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + // OccurredAtUtc renders ISO-8601 round-trip ("o" format). The + // year+time fragment is sufficient evidence — the full ISO string + // changes shape with locale-dependent formatting in some envs. + Assert.Contains("data-test=\"field-OccurredAtUtc\"", cut.Markup); + Assert.Contains("2026-05-20T12:30:45", cut.Markup); + } + + [Fact] + public void Drawer_JsonRequestSummary_PrettyPrinted_Indented() + { + // A single-line JSON body should be re-emitted indented. + var ev = MakeEvent(requestSummary: "{\"a\":1,\"b\":\"two\"}"); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + // Pretty-print writes one property per line — the " \"a\":" prefix + // proves indentation. We don't pin the exact bytes; we pin "indented" + // by looking for newline-prefixed property lines. + Assert.Contains("data-test=\"request-body\"", cut.Markup); + Assert.Matches(@"\n\s+""a"":\s*1", cut.Markup); + Assert.Matches(@"\n\s+""b"":\s*""two""", cut.Markup); + } + + [Fact] + public void Drawer_NonJsonRequestSummary_RenderedVerbatim() + { + // Non-JSON content (e.g. plain text or invalid JSON) must round-trip + // exactly — the drawer should not attempt to "fix" or rewrite it. + var ev = MakeEvent(requestSummary: "not really json {{}"); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + Assert.Contains("not really json {{}", cut.Markup); + } + + [Fact] + public void Drawer_DbOutboundChannel_RendersSqlBlock() + { + // DbOutbound payloads carry a {sql, parameters} JSON shape. The drawer + // renders sql inside a code block with language-sql class (CSS-only, + // no JS highlighter) and lists the parameters in a definition list. + const string body = "{\"sql\":\"UPDATE T SET x=@p1 WHERE id=@p2\",\"parameters\":{\"p1\":42,\"p2\":\"abc\"}}"; + var ev = MakeEvent(channel: AuditChannel.DbOutbound, kind: AuditKind.DbWrite, requestSummary: body); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + Assert.Contains("language-sql", cut.Markup); + Assert.Contains("UPDATE T SET x=@p1 WHERE id=@p2", cut.Markup); + // Parameter dl shows both keys. + Assert.Contains("p1", cut.Markup); + Assert.Contains("p2", cut.Markup); + Assert.Contains("42", cut.Markup); + Assert.Contains("abc", cut.Markup); + } + + [Fact] + public void Drawer_ApiOutbound_ShowsCopyAsCurlButton() + { + var ev = MakeEvent(channel: AuditChannel.ApiOutbound); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + Assert.Contains("data-test=\"copy-as-curl\"", cut.Markup); + } + + [Fact] + public void Drawer_NotApiChannel_HidesCopyAsCurlButton() + { + // Notification is neither an API outbound nor inbound — no cURL. + var ev = MakeEvent(channel: AuditChannel.Notification, kind: AuditKind.NotifySend); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + Assert.DoesNotContain("data-test=\"copy-as-curl\"", cut.Markup); + } + + [Fact] + public void Drawer_NullCorrelationId_HidesShowAllButton() + { + var ev = MakeEvent(correlationId: null); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + Assert.DoesNotContain("data-test=\"show-all-events\"", cut.Markup); + } + + [Fact] + public void Drawer_RedactedBody_ShowsRedactionBadge() + { + // The redaction sentinel is the literal string `` (or + // ``) — the drawer must flag it visibly. + var ev = MakeEvent(requestSummary: "{\"headers\":{\"Authorization\":\"\"},\"body\":\"hello\"}"); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + Assert.Contains("data-test=\"redaction-badge-request\"", cut.Markup); + } + + [Fact] + public void Drawer_NonRedactedBody_HidesBadge() + { + var ev = MakeEvent(requestSummary: "{\"headers\":{\"Authorization\":\"Bearer abc\"},\"body\":\"hello\"}"); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + Assert.DoesNotContain("data-test=\"redaction-badge-request\"", cut.Markup); + } + + [Fact] + public void ShowAllForOperation_Navigates_WithCorrelationIdQueryString() + { + var corr = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + var ev = MakeEvent(correlationId: corr); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + cut.Find("[data-test=\"show-all-events\"]").Click(); + + var nav = (BunitNavigationManager)Services.GetRequiredService(); + Assert.Contains("/audit/log?correlationId=", nav.Uri); + Assert.Contains(corr.ToString(), nav.Uri); + } + + [Fact] + public async Task CopyAsCurl_InvokesClipboard_WithCurlString() + { + // Set up Strict mode interop so the call must match exactly. + JSInterop.Mode = JSRuntimeMode.Strict; + var clipboardCall = JSInterop.SetupVoid( + "navigator.clipboard.writeText", + invocation => invocation.Arguments.Count == 1 + && invocation.Arguments[0] is string s + && s.StartsWith("curl ", StringComparison.Ordinal)); + + // Build an event with a {headers, body} RequestSummary so the cURL + // builder has material to fold in. + var ev = MakeEvent( + channel: AuditChannel.ApiOutbound, + target: "https://example.test/api/v1/widgets", + requestSummary: "{\"headers\":{\"Content-Type\":\"application/json\"},\"body\":\"{\\\"x\\\":1}\"}"); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + await cut.InvokeAsync(() => cut.Find("[data-test=\"copy-as-curl\"]").Click()); + + // Bunit's JSRuntimeInvocationDictionary is keyed by identifier + // (string) — we enumerate it instead of indexing by int. + var calls = clipboardCall.Invocations.ToList(); + Assert.NotEmpty(calls); + var argString = (string)calls[0].Arguments[0]!; + Assert.StartsWith("curl ", argString); + Assert.Contains("https://example.test/api/v1/widgets", argString); + Assert.Contains("Content-Type: application/json", argString); + } +} From 450f8bca287039ed3327ca9dfac842ac9a7642c1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 20:19:47 -0400 Subject: [PATCH 07/14] feat(ui): AuditLogPage parses query-string filters for drill-ins (#23 M7) --- .../Components/Audit/AuditFilterBar.razor.cs | 18 +++ .../Components/Pages/Audit/AuditLogPage.razor | 7 +- .../Pages/Audit/AuditLogPage.razor.cs | 104 ++++++++++++++++++ .../Pages/AuditLogPageScaffoldTests.cs | 100 ++++++++++++++++- 4 files changed, 226 insertions(+), 3 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs index 40ab3f4..ba052d3 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs @@ -32,8 +32,26 @@ public partial class AuditFilterBar /// [Parameter] public Func? NowUtcProvider { get; set; } + /// + /// Bundle D drill-in seam (#23 M7-T10..T12). When set on first render, + /// pre-populates the Instance free-text input. Instance is UI-only — the + /// repository filter contract has no instance column — so this flows in + /// through a separate parameter rather than the + /// the parent page passes to the grid. + /// + [Parameter] public string? InitialInstanceSearch { get; set; } + protected override async Task OnInitializedAsync() { + // One-shot prefill from a drill-in deep link. Subsequent parameter changes + // do NOT overwrite user input — the field is owned by the operator after + // first render. + if (!string.IsNullOrWhiteSpace(InitialInstanceSearch)) + { + _model.InstanceSearch = InitialInstanceSearch.Trim(); + } + + // Populate the Site chips at component init. Failure is non-fatal — the chip // section just shows "No sites available." Sites are listed by Name to match // operator expectations from the Notification Report. diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor index f47a807..1aadb03 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor @@ -11,9 +11,12 @@

Audit Log

- @* Filter bar (Bundle B / M7-T2). Apply hands the collapsed filter to the grid. *@ + @* Filter bar (Bundle B / M7-T2). Apply hands the collapsed filter to the grid. + Bundle D (M7-T10..T12) threads a query-string instance prefill through + InitialInstanceSearch — UI-only because the filter contract has no instance column. *@
- +
@* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs index 4ecc647..240e837 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs @@ -1,5 +1,8 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; namespace ScadaLink.CentralUI.Components.Pages.Audit; @@ -11,12 +14,113 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit; /// trigger). Row clicks land in — Bundle C wires /// this to the drilldown drawer; for now it is a no-op seam so test stubs do /// not error. +/// +/// +/// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can +/// deep-link to a pre-filtered Audit Log: ?correlationId=, ?target=, +/// ?actor=, ?site=, ?channel=, and the UI-only +/// ?instance= are read on initialization. When any param is present we +/// allocate a fresh and assign it to +/// , which kicks the results grid into auto-load +/// without the user clicking Apply. Unknown values (e.g. an invalid enum name) +/// are silently dropped — the page still renders, just without that constraint. +/// /// public partial class AuditLogPage { + [Inject] private NavigationManager Navigation { get; set; } = null!; + private AuditLogQueryFilter? _currentFilter; private AuditEvent? _selectedEvent; private bool _drawerOpen; + private string? _initialInstanceSearch; + + protected override void OnInitialized() + { + ApplyQueryStringFilters(); + } + + private void ApplyQueryStringFilters() + { + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + + if (query.Count == 0) + { + return; + } + + Guid? correlationId = null; + if (query.TryGetValue("correlationId", out var corrValues) + && Guid.TryParse(corrValues.ToString(), out var parsedCorr)) + { + correlationId = parsedCorr; + } + + string? target = null; + if (query.TryGetValue("target", out var targetValues)) + { + var v = targetValues.ToString(); + if (!string.IsNullOrWhiteSpace(v)) + { + target = v.Trim(); + } + } + + string? actor = null; + if (query.TryGetValue("actor", out var actorValues)) + { + var v = actorValues.ToString(); + if (!string.IsNullOrWhiteSpace(v)) + { + actor = v.Trim(); + } + } + + string? site = null; + if (query.TryGetValue("site", out var siteValues)) + { + var v = siteValues.ToString(); + if (!string.IsNullOrWhiteSpace(v)) + { + site = v.Trim(); + } + } + + AuditChannel? channel = null; + if (query.TryGetValue("channel", out var channelValues) + && Enum.TryParse(channelValues.ToString(), ignoreCase: true, out var parsedChannel)) + { + channel = parsedChannel; + } + + // Instance is UI-only — the filter contract has no matching column, so we + // pass it as a separate seam to the filter bar. + if (query.TryGetValue("instance", out var instanceValues)) + { + var v = instanceValues.ToString(); + if (!string.IsNullOrWhiteSpace(v)) + { + _initialInstanceSearch = v.Trim(); + } + } + + // If ANY filter-shaped param was provided, allocate the filter so the grid + // auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load + // because the filter contract has no instance column — the user still needs + // to refine + Apply for those. + if (correlationId is null && target is null && actor is null && site is null && channel is null) + { + return; + } + + _currentFilter = new AuditLogQueryFilter( + Channel: channel, + SourceSiteId: site, + Target: target, + Actor: actor, + CorrelationId: correlationId); + } private void HandleFilterChanged(AuditLogQueryFilter filter) { diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs index 6d5c07b..9852eb9 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs @@ -1,11 +1,15 @@ 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.Entities.Audit; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; using ScadaLink.Security; using AuditLogPage = ScadaLink.CentralUI.Components.Pages.Audit.AuditLogPage; using NavMenu = ScadaLink.CentralUI.Components.Layout.NavMenu; @@ -32,6 +36,13 @@ public class AuditLogPageScaffoldTests : BunitContext } private IRenderedComponent RenderAuditLogPage(params string[] roles) + { + return RenderAuditLogPageWithQuery(query: null, roles: roles); + } + + private IAuditLogQueryService _queryService = Substitute.For(); + + private IRenderedComponent RenderAuditLogPageWithQuery(string? query, params string[] roles) { var user = BuildPrincipal(roles); Services.AddSingleton(new TestAuthStateProvider(user)); @@ -42,7 +53,14 @@ public class AuditLogPageScaffoldTests : BunitContext // ISiteRepository and IAuditLogQueryService respectively (Bundle B). // Provide stand-ins so the scaffold smoke tests still render the page. Services.AddSingleton(Substitute.For()); - Services.AddSingleton(Substitute.For()); + Services.AddSingleton(_queryService); + + if (!string.IsNullOrEmpty(query)) + { + var nav = (BunitNavigationManager)Services.GetRequiredService(); + nav.NavigateTo($"/audit/log?{query}"); + } + return Render(); } @@ -112,4 +130,84 @@ public class AuditLogPageScaffoldTests : BunitContext "Audit section header must precede the Audit Log link."); }); } + + // ───────────────────────────────────────────────────────────────────────── + // Bundle D — query-string drill-in parsing (#23 M7-T10..T12) + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void NavigateWithCorrelationId_AppliesFilter_AndAutoLoads() + { + var corr = Guid.Parse("11111111-2222-3333-4444-555555555555"); + _queryService = Substitute.For(); + _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new List())); + + var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Admin"); + + cut.WaitForAssertion(() => + { + // Auto-load fires because correlationId is a real filter dimension. + _queryService.Received().QueryAsync( + Arg.Is(f => f.CorrelationId == corr), + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void NavigateWithTargetParam_AppliesTargetFilter() + { + _queryService = Substitute.For(); + _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new List())); + + var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Admin"); + + cut.WaitForAssertion(() => + { + _queryService.Received().QueryAsync( + Arg.Is(f => f.Target == "ExternalSystem-Alpha"), + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void NavigateWithSiteParam_AppliesSiteFilter() + { + _queryService = Substitute.For(); + _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new List())); + + var cut = RenderAuditLogPageWithQuery("site=plant-a", "Admin"); + + cut.WaitForAssertion(() => + { + _queryService.Received().QueryAsync( + Arg.Is(f => f.SourceSiteId == "plant-a"), + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad() + { + _queryService = Substitute.For(); + + var cut = RenderAuditLogPage("Admin"); + + // The grid is in "no filter" state — the page heading renders, but the + // query service must NOT be hit because nothing told us to load. + cut.WaitForAssertion(() => + { + Assert.Contains("Audit Log", cut.Markup); + }); + + _queryService.DidNotReceive().QueryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } } From 1c20e81d7749c330a44ebcce2d783d0a7e0ab78e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 20:20:54 -0400 Subject: [PATCH 08/14] feat(ui): drill-in from Notifications to Audit Log (#23 M7) --- .../Notifications/NotificationReport.razor | 12 ++-- .../Pages/NotificationReportPageTests.cs | 56 +++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor b/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor index 1a66d3d..b083824 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor @@ -163,6 +163,14 @@ + @* Bundle D (#23 M7-T10) drill-in: NotificationId is the audit + CorrelationId, so the link deep-links into the central Audit + Log pre-filtered to this notification's lifecycle events. *@ +
+ View audit history + @if (n.Status == "Parked") {
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor index 3c80438..7755334 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor @@ -20,7 +20,20 @@
-
@(IsEditMode ? "Edit Site" : "Add Site")
+
+
@(IsEditMode ? "Edit Site" : "Add Site")
+ @* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit + Log pre-filtered to this site's events. AuditEvent.SourceSiteId + stores the SiteIdentifier (string), so we pass that through. *@ + @if (IsEditMode && !string.IsNullOrWhiteSpace(_formIdentifier)) + { + + Recent audit activity + + } +

Configure Instance

+ @* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log + pre-filtered to this instance. Instance is UI-only on the filter bar + (AuditEvent has no Instance column), so we use the ?instance= UI-text + seam — the filter bar's Instance free-text input is pre-populated. *@ + @if (_instance != null) + { + + Recent audit activity + + }
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystemForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystemForm.razor index 8920202..0069cde 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystemForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystemForm.razor @@ -10,7 +10,20 @@
-

@(Id.HasValue ? "Edit External System" : "Add External System")

+
+

@(Id.HasValue ? "Edit External System" : "Add External System")

+ @* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log + pre-filtered to this external system's outbound API events. Audit rows + record the target by external-system name, so we filter on Target. *@ + @if (Id.HasValue && !string.IsNullOrWhiteSpace(_name)) + { + + Recent audit activity + + } +
@if (_loading) { diff --git a/tests/ScadaLink.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs b/tests/ScadaLink.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs new file mode 100644 index 0000000..5eb5a04 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs @@ -0,0 +1,72 @@ +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using NSubstitute; +using ScadaLink.Commons.Entities.InboundApi; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Security; +using ApiKeyForm = ScadaLink.CentralUI.Components.Pages.Admin.ApiKeyForm; + +namespace ScadaLink.CentralUI.Tests.Admin; + +/// +/// Bundle D drill-in test (#23 M7-T12) for the API Keys edit page. The chip +/// routes operators into the central Audit Log pre-filtered by Actor = ApiKey.Name +/// AND Channel = ApiInbound (no other channel uses the key name as actor, but +/// the explicit channel scope keeps deep links tight). Create mode suppresses +/// the link — there's no API key to drill into yet. +/// +public class ApiKeyFormAuditDrillinTests : BunitContext +{ + private readonly IInboundApiRepository _repo = Substitute.For(); + + public ApiKeyFormAuditDrillinTests() + { + Services.AddSingleton(_repo); + + var claims = new[] + { + new Claim("Username", "admin"), + new Claim(JwtTokenService.RoleClaimType, "Admin"), + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + AuthorizationPolicies.AddScadaLinkAuthorization(Services); + } + + [Fact] + public void EditPage_HasRecentAuditActivityLink_WithActorAndApiInboundChannel() + { + var key = ApiKey.FromHash("Orders-Integration", "k-hash"); + key.Id = 11; + _repo.GetApiKeyByIdAsync(11, Arg.Any()).Returns(key); + _repo.GetAllApiMethodsAsync(Arg.Any()) + .Returns(Task.FromResult>(new List())); + + var cut = Render(p => p.Add(c => c.Id, 11)); + + cut.WaitForAssertion(() => + { + var link = cut.Find("a[data-test=\"audit-link\"]"); + Assert.Equal( + "/audit/log?actor=Orders-Integration&channel=ApiInbound", + link.GetAttribute("href")); + Assert.Contains("Recent audit activity", link.TextContent); + }); + } + + [Fact] + public void CreatePage_HasNoRecentAuditActivityLink() + { + var cut = Render(); + + cut.WaitForAssertion(() => + { + Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]")); + }); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs b/tests/ScadaLink.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs new file mode 100644 index 0000000..265c30e --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs @@ -0,0 +1,73 @@ +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Communication; +using ScadaLink.Security; +using SiteForm = ScadaLink.CentralUI.Components.Pages.Admin.SiteForm; + +namespace ScadaLink.CentralUI.Tests.Admin; + +/// +/// Bundle D drill-in test (#23 M7-T12) for the Site edit page. The chip +/// routes operators into the central Audit Log pre-filtered by SourceSiteId = +/// Site.SiteIdentifier (the same string the audit pipeline stamps onto every +/// site-sourced row). Create mode suppresses the link — there's no site yet. +/// +public class SiteFormAuditDrillinTests : BunitContext +{ + private readonly ISiteRepository _siteRepo = Substitute.For(); + private readonly CommunicationService _comms; + + public SiteFormAuditDrillinTests() + { + _comms = new CommunicationService( + Options.Create(new CommunicationOptions()), + NullLogger.Instance); + Services.AddSingleton(_siteRepo); + Services.AddSingleton(_comms); + + var claims = new[] + { + new Claim("Username", "admin"), + new Claim(JwtTokenService.RoleClaimType, "Admin"), + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + AuthorizationPolicies.AddScadaLinkAuthorization(Services); + } + + [Fact] + public void EditPage_HasRecentAuditActivityLink_WithSiteEqualToSiteIdentifier() + { + _siteRepo.GetSiteByIdAsync(3, Arg.Any()) + .Returns(new Site("Plant A", "plant-a") { Id = 3 }); + + var cut = Render(p => p.Add(c => c.Id, 3)); + + cut.WaitForAssertion(() => + { + var link = cut.Find("a[data-test=\"audit-link\"]"); + Assert.Equal("/audit/log?site=plant-a", link.GetAttribute("href")); + Assert.Contains("Recent audit activity", link.TextContent); + }); + } + + [Fact] + public void CreatePage_HasNoRecentAuditActivityLink() + { + var cut = Render(); + + cut.WaitForAssertion(() => + { + Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]")); + }); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs b/tests/ScadaLink.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs new file mode 100644 index 0000000..25a2657 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs @@ -0,0 +1,100 @@ +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ScadaLink.CentralUI.Auth; +using ScadaLink.Commons.Entities.Instances; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.Commons.Entities.Templates; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.DeploymentManager; +using ScadaLink.Security; +using ScadaLink.TemplateEngine.Services; +using InstanceConfigurePage = ScadaLink.CentralUI.Components.Pages.Deployment.InstanceConfigure; + +namespace ScadaLink.CentralUI.Tests.Deployment; + +/// +/// Bundle D drill-in test (#23 M7-T12) for the Instance Configure page. The +/// chip routes operators into the central Audit Log pre-filtered by +/// ?instance={Instance.UniqueName}. Instance is UI-only on the filter +/// bar (the repository filter contract has no instance column), so the page +/// uses the UI-text seam — the Audit Log's filter bar pre-populates its +/// Instance free-text input from this query string. +/// +public class InstanceConfigureAuditDrillinTests : BunitContext +{ + private readonly ITemplateEngineRepository _templateRepo = + Substitute.For(); + private readonly ISiteRepository _siteRepo = Substitute.For(); + + public InstanceConfigureAuditDrillinTests() + { + // Loose JS interop because shared components on the page render + // localStorage / clipboard touches that we don't care about here. + JSInterop.Mode = JSRuntimeMode.Loose; + + Services.AddSingleton(_templateRepo); + Services.AddSingleton(_siteRepo); + + Services.AddSingleton(new InstanceService(_templateRepo, Substitute.For())); + Services.AddSingleton(Substitute.For()); + + // Auth: a system-wide Deployment user so SiteScope grants everything. + var claims = new[] + { + new Claim("Username", "deployer"), + new Claim(JwtTokenService.RoleClaimType, "Deployment"), + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + var authProvider = new TestAuthStateProvider(user); + Services.AddSingleton(authProvider); + Services.AddSingleton(new SiteScopeService(authProvider)); + Services.AddAuthorizationCore(); + AuthorizationPolicies.AddScadaLinkAuthorization(Services); + } + + [Fact] + public void Page_HasRecentAuditActivityLink_WithInstanceUniqueName() + { + var instance = new Instance("Pump-Station-007") + { + Id = 42, + TemplateId = 1, + SiteId = 1, + State = ScadaLink.Commons.Types.Enums.InstanceState.NotDeployed, + }; + + _templateRepo.GetInstanceByIdAsync(42, Arg.Any()).Returns(instance); + _templateRepo.GetTemplateByIdAsync(1, Arg.Any()) + .Returns(new Template("Pump") { Id = 1 }); + _siteRepo.GetAllSitesAsync(Arg.Any()) + .Returns(new List { new("Plant A", "plant-a") { Id = 1 } }); + _templateRepo.GetAreasBySiteIdAsync(1, Arg.Any()) + .Returns(new List()); + _templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any()) + .Returns(new List()); + _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) + .Returns(new List()); + _templateRepo.GetBindingsByInstanceIdAsync(42, Arg.Any()) + .Returns(new List()); + _templateRepo.GetOverridesByInstanceIdAsync(42, Arg.Any()) + .Returns(new List()); + _templateRepo.GetAlarmsByTemplateIdAsync(1, Arg.Any()) + .Returns(new List()); + _templateRepo.GetAlarmOverridesByInstanceIdAsync(42, Arg.Any()) + .Returns(new List()); + + var cut = Render(p => p.Add(c => c.Id, 42)); + + cut.WaitForAssertion(() => + { + var link = cut.Find("a[data-test=\"audit-link\"]"); + Assert.Equal("/audit/log?instance=Pump-Station-007", link.GetAttribute("href")); + Assert.Contains("Recent audit activity", link.TextContent); + }); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs b/tests/ScadaLink.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs new file mode 100644 index 0000000..cf685ef --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs @@ -0,0 +1,70 @@ +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ScadaLink.Commons.Entities.ExternalSystems; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Security; +using ExternalSystemForm = ScadaLink.CentralUI.Components.Pages.Design.ExternalSystemForm; + +namespace ScadaLink.CentralUI.Tests.Design; + +/// +/// Bundle D drill-in test (#23 M7-T12) for the External Systems edit page. +/// The page-header chip routes operators into the central Audit Log +/// pre-filtered by Target = external-system name. Create mode has nothing +/// to drill into yet, so the link is suppressed. +/// +public class ExternalSystemFormAuditDrillinTests : BunitContext +{ + private readonly IExternalSystemRepository _repo = Substitute.For(); + + public ExternalSystemFormAuditDrillinTests() + { + Services.AddSingleton(_repo); + + var claims = new[] + { + new Claim("Username", "tester"), + new Claim(JwtTokenService.RoleClaimType, "Design"), + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + AuthorizationPolicies.AddScadaLinkAuthorization(Services); + } + + [Fact] + public void EditPage_HasRecentAuditActivityLink_WithTargetEqualToSystemName() + { + _repo.GetExternalSystemByIdAsync(7, Arg.Any()) + .Returns(new ExternalSystemDefinition("ERP-Alpha", "https://erp.example.test", "ApiKey") + { + Id = 7, + }); + + var cut = Render(p => p.Add(c => c.Id, 7)); + + cut.WaitForAssertion(() => + { + var link = cut.Find("a[data-test=\"audit-link\"]"); + Assert.Equal("/audit/log?target=ERP-Alpha", link.GetAttribute("href")); + Assert.Contains("Recent audit activity", link.TextContent); + }); + } + + [Fact] + public void CreatePage_HasNoRecentAuditActivityLink() + { + // Create mode (Id is null) — there's no real external system to drill into, + // so the link must not render. + var cut = Render(); + + cut.WaitForAssertion(() => + { + Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]")); + }); + } +} From 943c2ced3976139f496e777818e152fe433e9ad0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 20:43:57 -0400 Subject: [PATCH 10/14] feat(ui): Audit KPI tiles on Health dashboard (#23 M7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three KPI tiles to the central Health dashboard for the Audit channel: volume (rows in the last hour), error rate (Failed/Parked/Discarded over total), and backlog (sum of SiteAuditBacklog.PendingCount across all sites). Repo + service: - IAuditLogRepository.GetKpiSnapshotAsync(window, nowUtc) — single aggregate SELECT over the trailing window returning total + error counts; nowUtc is optional for production callers and pinned by integration tests against the shared MSSQL fixture so the global counts are deterministic. - AuditLogQueryService.GetKpiSnapshotAsync() — composes the repo aggregate with a sum of SiteAuditBacklog.PendingCount read from ICentralHealthAggregator. - AuditLogKpiSnapshot record in Commons/Types/. UI: - New AuditKpiTiles Blazor component (Components/Health/) — three Bootstrap card-tiles, click navigates to /audit/log with the matching pre-filter. - Health.razor wires the tiles in alongside the existing Notification Outbox KPIs; LoadAuditKpis() runs on every 10s refresh tick and degrades to em dashes + inline error if the query fails. - AuditLogPage extended to parse ?status= so the error-rate tile drill-in (?status=Failed) auto-loads the grid. Tests: - AuditLogRepositoryTests: GetKpiSnapshotAsync mixed-status + empty-window cases against the MSSQL migration fixture. - AuditLogQueryServiceTests: forwarding + backlog composition; sites with null SiteAuditBacklog contribute zero. - AuditKpiTilesTests: 9 bUnit tests covering tile render, error-rate maths with safe zero-events handling, em-dash unavailable path, click-through navigation, and warning/danger border thresholds. - HealthPageTests: new Renders_AuditKpiTiles_WithValues plus IAuditLogQueryService stub registration in the constructor so existing outbox tests still pass. - AuditLogPageScaffoldTests: ?status=Failed auto-load + unknown status drop. --- .../Components/Health/AuditKpiTiles.razor | 59 +++++++ .../Components/Health/AuditKpiTiles.razor.cs | 157 +++++++++++++++++ .../Pages/Audit/AuditLogPage.razor.cs | 20 ++- .../Components/Pages/Monitoring/Health.razor | 36 ++++ .../Services/AuditLogQueryService.cs | 41 ++++- .../Services/IAuditLogQueryService.cs | 23 +++ .../Repositories/IAuditLogRepository.cs | 47 ++++++ .../Types/AuditLogKpiSnapshot.cs | 38 +++++ .../Repositories/AuditLogRepository.cs | 114 +++++++++++++ .../Central/AuditLogIngestActorTests.cs | 4 + .../Central/AuditLogPurgeActorTests.cs | 4 + .../Central/CentralAuditWriteFailuresTests.cs | 3 + .../SiteAuditReconciliationActorTests.cs | 4 + .../Components/Health/AuditKpiTilesTests.cs | 158 ++++++++++++++++++ .../Pages/AuditLogPageScaffoldTests.cs | 38 +++++ .../Pages/HealthPageTests.cs | 41 +++++ .../Services/AuditLogQueryServiceTests.cs | 113 ++++++++++++- .../Repositories/AuditLogRepositoryTests.cs | 76 +++++++++ 18 files changed, 969 insertions(+), 7 deletions(-) create mode 100644 src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor create mode 100644 src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor.cs create mode 100644 src/ScadaLink.Commons/Types/AuditLogKpiSnapshot.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Components/Health/AuditKpiTilesTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor b/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor new file mode 100644 index 0000000..0113fc0 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor @@ -0,0 +1,59 @@ +@* + Audit Log (#23) M7 Bundle E (T13) — three Health-dashboard KPI tiles for the + Audit channel: Volume / Error rate / Backlog. Renders Bootstrap card tiles in + a single row, each acting as a navigation link to a pre-filtered Audit Log + view. The component is purely presentational — the parent page owns the + refresh loop and passes the latest snapshot via the Snapshot parameter. +*@ + +@namespace ScadaLink.CentralUI.Components.Health +@inject NavigationManager Navigation + +
+
Audit
+ View details → +
+
+ @* ── Volume tile ───────────────────────────────────────────────────────── *@ +
+ +
+ + @* ── Error rate tile ───────────────────────────────────────────────────── *@ +
+ +
+ + @* ── Backlog tile ──────────────────────────────────────────────────────── *@ +
+ +
+
+@if (!IsAvailable && !string.IsNullOrEmpty(ErrorMessage)) +{ +
Audit KPIs unavailable: @ErrorMessage
+} diff --git a/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor.cs b/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor.cs new file mode 100644 index 0000000..5c6ede1 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor.cs @@ -0,0 +1,157 @@ +using Microsoft.AspNetCore.Components; +using ScadaLink.Commons.Types; + +namespace ScadaLink.CentralUI.Components.Health; + +/// +/// Audit Log (#23) M7 Bundle E (T13) code-behind for . +/// Renders three KPI tiles — volume, error rate, backlog — from a +/// the parent page supplies. Tiles act as +/// drill-in links: clicking navigates to /audit/log with the relevant +/// query-string filter pre-applied (Bundle D already parses these params). +/// +/// +/// +/// Why purely presentational. The Health dashboard already owns a 10s +/// auto-refresh loop and an "as-of" timestamp display; pushing those concerns +/// into the tile component would either duplicate them (one timer per tile) or +/// awkwardly couple back to the page. The parent passes a fresh +/// every refresh and the tile component +/// re-renders. +/// +/// +/// Error rate division. When TotalEventsLastHour == 0 we render +/// "0%" rather than "—" — the snapshot itself is available, the system just had +/// no audit traffic to evaluate. This avoids a divide-by-zero AND keeps the +/// "0% errors" reading semantically true. The em dash is reserved for +/// = false, which represents a failed snapshot +/// query (different signal from "quiet hour"). +/// +/// +public partial class AuditKpiTiles +{ + /// + /// Latest KPI snapshot. null means the parent has not loaded it yet + /// or the load failed — the tiles render em dashes in that case. + /// + [Parameter] public AuditLogKpiSnapshot? Snapshot { get; set; } + + /// + /// True when is a successful query result. False + /// when the parent's refresh threw and the displayed values should be + /// rendered as em dashes with an error explanation underneath. + /// + [Parameter] public bool IsAvailable { get; set; } + + /// + /// Optional error message to render underneath the tiles when + /// is false. Mirrors how the Notification Outbox + /// section on the Health dashboard surfaces transient KPI failures. + /// + [Parameter] public string? ErrorMessage { get; set; } + + // ── Volume tile ───────────────────────────────────────────────────────── + + private string VolumeDisplay => + IsAvailable && Snapshot is not null + ? Snapshot.TotalEventsLastHour.ToString("N0") + : "—"; + + private void NavigateToVolume() + { + // Volume is "all audit rows in the last hour" — no status filter; the + // page's existing instance-search seam is enough for drill-in. We rely + // on the page's default render which omits a time-range constraint and + // shows the newest rows first. + Navigation.NavigateTo("/audit/log"); + } + + // ── Error rate tile ───────────────────────────────────────────────────── + + /// + /// Percentage of error rows (Failed/Parked/Discarded) over the trailing + /// hour. Returns 0 when the snapshot is unavailable OR when total events + /// is zero (rather than throwing). The display layer renders "—" for the + /// unavailable case and "0%" for the zero-events case. + /// + internal double ErrorRatePercent + { + get + { + if (!IsAvailable || Snapshot is null || Snapshot.TotalEventsLastHour <= 0) + { + return 0; + } + return 100.0 * Snapshot.ErrorEventsLastHour / Snapshot.TotalEventsLastHour; + } + } + + private string ErrorRateDisplay + { + get + { + if (!IsAvailable || Snapshot is null) + { + return "—"; + } + // Format to one decimal so a 1-error-in-2000 rate doesn't round to 0%. + return $"{ErrorRatePercent:0.0}%"; + } + } + + // Border + text colour bracket the tile visually: any nonzero error rate + // gets a warning border; anything above 10% bumps it to danger. The + // thresholds match the Notification Outbox tile pattern (border-warning + // when Stuck > 0, border-danger when Parked > 0). + private string ErrorRateBorderClass => + !IsAvailable || Snapshot is null || Snapshot.ErrorEventsLastHour == 0 + ? string.Empty + : (ErrorRatePercent >= 10 ? "border-danger" : "border-warning"); + + private string ErrorRateTextClass => + !IsAvailable || Snapshot is null || Snapshot.ErrorEventsLastHour == 0 + ? string.Empty + : (ErrorRatePercent >= 10 ? "text-danger" : "text-warning"); + + private void NavigateToErrors() + { + // Drill in pre-filtered to Failed — the most common error class. + // (The Audit Log page also accepts ?status=Parked / =Discarded for + // operators who want to see those specifically; the tile picks Failed + // as the primary surface since it's the only synchronous-failure + // status. Parked + Discarded both still appear in the unfiltered grid.) + Navigation.NavigateTo("/audit/log?status=Failed"); + } + + // ── Backlog tile ──────────────────────────────────────────────────────── + + private string BacklogDisplay => + IsAvailable && Snapshot is not null + ? Snapshot.BacklogTotal.ToString("N0") + : "—"; + + // Backlog above zero is itself a signal — sites should normally drain to + // empty. We render warning when there's a backlog at all; a hard danger + // threshold could be added later if ops want it but the on-call playbook + // for "backlog > 0" is the same as "backlog > 1000": check why the site + // isn't draining. + private string BacklogBorderClass => + IsAvailable && Snapshot is not null && Snapshot.BacklogTotal > 0 + ? "border-warning" + : string.Empty; + + private string BacklogTextClass => + IsAvailable && Snapshot is not null && Snapshot.BacklogTotal > 0 + ? "text-warning" + : string.Empty; + + private void NavigateToBacklog() + { + // The audit-log page itself doesn't carry a per-site backlog grid — + // the Health dashboard already shows that per-site card. The natural + // drill-in for "the system has a backlog" is the unfiltered Audit Log + // page sorted by newest, so an operator can see the most recent rows + // and judge whether the queue is moving. + Navigation.NavigateTo("/audit/log"); + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs index 240e837..bc26789 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs @@ -19,8 +19,10 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit; /// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can /// deep-link to a pre-filtered Audit Log: ?correlationId=, ?target=, /// ?actor=, ?site=, ?channel=, and the UI-only -/// ?instance= are read on initialization. When any param is present we -/// allocate a fresh and assign it to +/// ?instance= are read on initialization. Bundle E (M7-T13) extends +/// this with ?status= so the Health-dashboard Audit error-rate tile can +/// drill in to ?status=Failed. When any param is present we allocate a +/// fresh and assign it to /// , which kicks the results grid into auto-load /// without the user clicking Apply. Unknown values (e.g. an invalid enum name) /// are silently dropped — the page still renders, just without that constraint. @@ -94,6 +96,17 @@ public partial class AuditLogPage channel = parsedChannel; } + // Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills in + // with ?status=Failed (and operators may craft URLs with Parked/Discarded). + // Unknown values are silently dropped — the page still renders without + // the constraint. + AuditStatus? status = null; + if (query.TryGetValue("status", out var statusValues) + && Enum.TryParse(statusValues.ToString(), ignoreCase: true, out var parsedStatus)) + { + status = parsedStatus; + } + // Instance is UI-only — the filter contract has no matching column, so we // pass it as a separate seam to the filter bar. if (query.TryGetValue("instance", out var instanceValues)) @@ -109,13 +122,14 @@ public partial class AuditLogPage // auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load // because the filter contract has no instance column — the user still needs // to refine + Apply for those. - if (correlationId is null && target is null && actor is null && site is null && channel is null) + if (correlationId is null && target is null && actor is null && site is null && channel is null && status is null) { return; } _currentFilter = new AuditLogQueryFilter( Channel: channel, + Status: status, SourceSiteId: site, Target: target, Actor: actor, diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor index bbff99b..58a87d1 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor @@ -1,5 +1,8 @@ @page "/monitoring/health" @attribute [Authorize] +@using ScadaLink.CentralUI.Components.Health +@using ScadaLink.CentralUI.Services +@using ScadaLink.Commons.Types @using ScadaLink.Commons.Types.Enums @using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Interfaces.Repositories @@ -10,6 +13,7 @@ @inject ICentralHealthAggregator HealthAggregator @inject ISiteRepository SiteRepository @inject CommunicationService CommunicationService +@inject IAuditLogQueryService AuditLogQueryService
@@ -56,6 +60,12 @@
Notification Outbox KPIs unavailable: @_outboxKpiError
} + @* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel + (volume / error rate / backlog). Refreshed alongside the site states. *@ + + @if (_siteStates.Count == 0) {
No site health reports received yet.
@@ -347,6 +357,13 @@ private bool _outboxKpiAvailable; private string? _outboxKpiError; + // Audit Log (#23) M7 Bundle E — Audit KPI tiles. Volume + error rate come + // from a 1h aggregate over the central AuditLog table; backlog sums the + // per-site SiteAuditBacklog.PendingCount via the health aggregator. + private AuditLogKpiSnapshot? _auditKpi; + private bool _auditKpiAvailable; + private string? _auditKpiError; + private static bool SiteHasActiveErrors(SiteHealthState state) { var report = state.LatestReport; @@ -384,6 +401,7 @@ { _siteStates = HealthAggregator.GetAllSiteStates(); await LoadOutboxKpis(); + await LoadAuditKpis(); } private async Task LoadOutboxKpis() @@ -416,6 +434,24 @@ private string OutboxTileValue(int value) => _outboxKpiAvailable ? value.ToString() : "—"; + // Audit KPI loader: wraps the service call so a transient DB outage degrades + // the three tiles to em dashes with an inline error rather than killing the + // dashboard. Mirrors LoadOutboxKpis's error handling shape. + private async Task LoadAuditKpis() + { + try + { + _auditKpi = await AuditLogQueryService.GetKpiSnapshotAsync(); + _auditKpiAvailable = true; + _auditKpiError = null; + } + catch (Exception ex) + { + _auditKpiAvailable = false; + _auditKpiError = $"KPI query failed: {ex.Message}"; + } + } + private string GetSiteName(string siteId) { return _siteNames.GetValueOrDefault(siteId, siteId); diff --git a/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs index 971960e..6a566b3 100644 --- a/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs +++ b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs @@ -1,6 +1,8 @@ using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Audit; +using ScadaLink.HealthMonitoring; namespace ScadaLink.CentralUI.Services; @@ -11,11 +13,21 @@ namespace ScadaLink.CentralUI.Services; /// public sealed class AuditLogQueryService : IAuditLogQueryService { - private readonly IAuditLogRepository _repository; + // M7 Bundle E (T13): trailing window for the Health dashboard's Audit KPI tiles. + // Hard-coded here rather than configurable because the requirement + // (Component-AuditLog.md §"Health & KPIs") fixes "rows/min over the last hour" + // and "% errors over the last hour" as the KPI definition. + private static readonly TimeSpan KpiWindow = TimeSpan.FromHours(1); - public AuditLogQueryService(IAuditLogRepository repository) + private readonly IAuditLogRepository _repository; + private readonly ICentralHealthAggregator _healthAggregator; + + public AuditLogQueryService( + IAuditLogRepository repository, + ICentralHealthAggregator healthAggregator) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator)); } public int DefaultPageSize => 100; @@ -29,4 +41,29 @@ public sealed class AuditLogQueryService : IAuditLogQueryService var effective = paging ?? new AuditLogPaging(DefaultPageSize); return _repository.QueryAsync(filter, effective, ct); } + + /// + public async Task GetKpiSnapshotAsync(CancellationToken ct = default) + { + // 1. Volume + error counts: aggregate over the trailing 1h window. + // BacklogTotal is left at 0 by the repository — we fill it from the + // in-memory health aggregator below. + var repoSnapshot = await _repository.GetKpiSnapshotAsync(KpiWindow, nowUtc: null, ct); + + // 2. Backlog: sum PendingCount across every site's latest report. + // Sites that have not yet reported or whose reporter is disabled + // leave SiteAuditBacklog null — those contribute zero (a Missing + // snapshot is "unknown", not "zero", but the tile is best-effort). + long backlog = 0; + foreach (var state in _healthAggregator.GetAllSiteStates().Values) + { + var pending = state.LatestReport?.SiteAuditBacklog?.PendingCount; + if (pending is > 0) + { + backlog += pending.Value; + } + } + + return repoSnapshot with { BacklogTotal = backlog }; + } } diff --git a/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs index b9236f9..08b85d8 100644 --- a/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs +++ b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs @@ -1,4 +1,5 @@ using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Audit; namespace ScadaLink.CentralUI.Services; @@ -27,4 +28,26 @@ public interface IAuditLogQueryService /// Default page size when callers don't specify one. int DefaultPageSize { get; } + + /// + /// Audit Log (#23) M7 Bundle E (T13) — returns the point-in-time KPI snapshot + /// the Health dashboard's Audit tiles render. Composes: + /// + /// TotalEventsLastHour + ErrorEventsLastHour from + /// + /// (1-hour trailing window). + /// BacklogTotal from the sum of every site's + /// SiteHealthReport.SiteAuditBacklog.PendingCount via + /// . + /// + /// + /// + /// Repository + aggregator are read independently; if either source has no + /// data the corresponding field is zero (a real signal — "no events" vs + /// "no backlog" — rather than an error). The service does NOT swallow + /// exceptions; the page wraps the call in a try/catch so a transient DB + /// outage degrades the tile group to "unavailable" rather than killing the + /// dashboard. + /// + Task GetKpiSnapshotAsync(CancellationToken ct = default); } diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs index bcda482..36b0d0f 100644 --- a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs +++ b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs @@ -1,4 +1,5 @@ using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Audit; namespace ScadaLink.Commons.Interfaces.Repositories; @@ -87,4 +88,50 @@ public interface IAuditLogRepository Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default); + + /// + /// Audit Log (#23) M7 Bundle E (T13) — returns aggregate counts over the + /// trailing driving the central Health + /// dashboard's Audit KPI tiles. + /// + /// + /// Trailing time window (e.g. TimeSpan.FromHours(1)). Rows whose + /// OccurredAtUtc >= nowUtc - window are counted; the upper + /// bound is . + /// + /// + /// Optional explicit "now" timestamp used to anchor the trailing window. + /// Defaults to at call time when null — + /// production callers should leave this null; tests pin a deterministic + /// value so the window is reproducible across runs. + /// + /// Cancellation token. + /// + /// A snapshot with TotalEventsLastHour + ErrorEventsLastHour + /// populated; BacklogTotal is left at zero (this method has no + /// visibility into per-site backlogs — the service layer composes it in + /// from ). + /// AsOfUtc is set to the server-side UtcNow at the time of + /// the query. + /// + /// + /// + /// Implemented as a single aggregate query + /// (SELECT COUNT_BIG(*) AS Total, SUM(CASE …) AS Errors) rather than + /// two round trips so the volume + error rate tiles read a consistent + /// snapshot — the denominator and numerator come from the same scan. + /// + /// + /// Errors are defined as , + /// , or + /// + /// — every non-success terminal lifecycle state. Submitted, + /// Forwarded, Attempted are in-flight and are NOT errors; + /// Delivered is success; Skipped is an intentional no-op. + /// + /// + Task GetKpiSnapshotAsync( + TimeSpan window, + DateTime? nowUtc = null, + CancellationToken ct = default); } diff --git a/src/ScadaLink.Commons/Types/AuditLogKpiSnapshot.cs b/src/ScadaLink.Commons/Types/AuditLogKpiSnapshot.cs new file mode 100644 index 0000000..83cd17a --- /dev/null +++ b/src/ScadaLink.Commons/Types/AuditLogKpiSnapshot.cs @@ -0,0 +1,38 @@ +namespace ScadaLink.Commons.Types; + +/// +/// Audit Log (#23) M7 Bundle E (T13) — point-in-time KPI snapshot for the central +/// Health dashboard's "Audit" tile group. Aggregates volume + error counts over +/// the trailing window from the central AuditLog table and combines them +/// with the global pending backlog summed across every site's +/// . +/// +/// +/// Total AuditLog rows whose OccurredAtUtc falls inside the trailing +/// 1-hour window. Drives the "Audit volume" tile and the denominator of +/// "Audit error rate". A zero value renders as "0" rather than an em dash — +/// "zero rows in the last hour" is a real, valid signal in a quiet system. +/// +/// +/// Total AuditLog rows in the same window whose +/// is Failed, Parked, or Discarded. Drives the "Audit error +/// rate" tile numerator; clicking the tile drills in to /audit/log +/// pre-filtered on one of those statuses. +/// +/// +/// Sum of SiteAuditBacklog.PendingCount across every site's latest +/// . Sites whose +/// snapshot is null (no report yet, or reporter not running) contribute +/// zero. A persistently non-zero value across multiple refresh ticks indicates +/// the site→central drain isn't keeping up. +/// +/// +/// UTC timestamp at which the snapshot was computed. Used by the UI to label +/// "as of HH:mm:ss" beneath the tile group and to detect stale data when a +/// refresh tick fails. +/// +public sealed record AuditLogKpiSnapshot( + long TotalEventsLastHour, + long ErrorEventsLastHour, + long BacklogTotal, + DateTime AsOfUtc); diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs index d2d74ac..f517a8e 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Audit; namespace ScadaLink.ConfigurationDatabase.Repositories; @@ -421,4 +422,117 @@ VALUES return results; } + + /// + /// M7-T13 Bundle E — Health-dashboard Audit KPI tiles aggregate query. + /// Single round-trip + /// (SELECT COUNT_BIG(*) AS Total, SUM(CASE WHEN Status IN (...) THEN 1 ELSE 0 END) AS Errors) + /// over the trailing anchored at + /// . Returns a snapshot with + /// left at zero — the service + /// layer composes that in from + /// . + /// + /// + /// + /// Why one query, not two: keeping the numerator + denominator in the same + /// scan means the error rate the UI displays is computed from a consistent + /// snapshot. With two separate queries a row could be inserted between + /// them, inflating the denominator past the numerator (or vice-versa) and + /// briefly producing a misleading percentage. + /// + /// + /// "Error" rows are Failed, Parked, or Discarded — see + /// for the rationale. + /// We pass the three discriminator strings as separate parameters rather + /// than building an IN-list to keep the prepared statement cache-friendly. + /// + /// + public async Task GetKpiSnapshotAsync( + TimeSpan window, + DateTime? nowUtc = null, + CancellationToken ct = default) + { + var anchorUtc = (nowUtc ?? DateTime.UtcNow).ToUniversalTime(); + var thresholdUtc = anchorUtc - window; + + // ExecuteSqlInterpolated parameterises every interpolation — the enum + // discriminators are passed as varchar parameters that match the + // varchar(32) Status column (HasConversion()). + var failedStr = nameof(Commons.Types.Enums.AuditStatus.Failed); + var parkedStr = nameof(Commons.Types.Enums.AuditStatus.Parked); + var discardedStr = nameof(Commons.Types.Enums.AuditStatus.Discarded); + + long total = 0; + long errors = 0; + + var conn = _context.Database.GetDbConnection(); + var openedHere = false; + if (conn.State != System.Data.ConnectionState.Open) + { + await conn.OpenAsync(ct).ConfigureAwait(false); + openedHere = true; + } + + try + { + await using var cmd = conn.CreateCommand(); + // Named parameters keep the prepared statement cache stable across + // calls — only the values change. COUNT_BIG returns a bigint so + // we read into long even when the running total fits in int. + cmd.CommandText = @" + SELECT + COUNT_BIG(*) AS Total, + SUM(CASE WHEN Status IN (@failed, @parked, @discarded) THEN 1 ELSE 0 END) AS Errors + FROM dbo.AuditLog + WHERE OccurredAtUtc >= @threshold + AND OccurredAtUtc <= @anchor;"; + + var pThreshold = cmd.CreateParameter(); + pThreshold.ParameterName = "@threshold"; + pThreshold.Value = thresholdUtc; + cmd.Parameters.Add(pThreshold); + + var pAnchor = cmd.CreateParameter(); + pAnchor.ParameterName = "@anchor"; + pAnchor.Value = anchorUtc; + cmd.Parameters.Add(pAnchor); + + var pFailed = cmd.CreateParameter(); + pFailed.ParameterName = "@failed"; + pFailed.Value = failedStr; + cmd.Parameters.Add(pFailed); + + var pParked = cmd.CreateParameter(); + pParked.ParameterName = "@parked"; + pParked.Value = parkedStr; + cmd.Parameters.Add(pParked); + + var pDiscarded = cmd.CreateParameter(); + pDiscarded.ParameterName = "@discarded"; + pDiscarded.Value = discardedStr; + cmd.Parameters.Add(pDiscarded); + + await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + if (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + // SUM over an empty set is NULL; COUNT_BIG over an empty set is 0. + total = reader.IsDBNull(0) ? 0L : reader.GetInt64(0); + errors = reader.IsDBNull(1) ? 0L : Convert.ToInt64(reader.GetValue(1)); + } + } + finally + { + if (openedHere) + { + await conn.CloseAsync().ConfigureAwait(false); + } + } + + return new AuditLogKpiSnapshot( + TotalEventsLastHour: total, + ErrorEventsLastHour: errors, + BacklogTotal: 0L, + AsOfUtc: anchorUtc); + } } diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs index 724ae68..51a0bb7 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs @@ -220,5 +220,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => _inner.GetPartitionBoundariesOlderThanAsync(threshold, ct); + + public Task GetKpiSnapshotAsync( + TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => + _inner.GetKpiSnapshotAsync(window, nowUtc, ct); } } diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs index afa20bf..241b720 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs @@ -78,6 +78,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture>(Boundaries.ToArray()); } + + public Task GetKpiSnapshotAsync( + TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => + Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); } private IServiceProvider BuildScopedProvider(IAuditLogRepository repo) diff --git a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs index 32b0a9a..b4d3569 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs @@ -48,6 +48,9 @@ public class CentralAuditWriteFailuresTests : TestKit public Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); + public Task GetKpiSnapshotAsync( + TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => + Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); } /// diff --git a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs index 5cbcfe9..87b5024 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs @@ -93,6 +93,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); + + public Task GetKpiSnapshotAsync( + TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => + Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); } /// diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Health/AuditKpiTilesTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Health/AuditKpiTilesTests.cs new file mode 100644 index 0000000..2c47dc8 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Components/Health/AuditKpiTilesTests.cs @@ -0,0 +1,158 @@ +using Bunit; +using Bunit.TestDoubles; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.CentralUI.Components.Health; +using ScadaLink.Commons.Types; + +namespace ScadaLink.CentralUI.Tests.Components.Health; + +/// +/// bUnit tests for (#23 M7 Bundle E / M7-T13). The +/// component renders three Bootstrap-card tiles — Volume, Error Rate, Backlog — +/// from a single . The tests pin: +/// +/// +/// Three-tile render contract (data-test attributes for stable selectors). +/// Error-rate maths: ErrorEventsLastHour / TotalEventsLastHour with +/// safe zero-events handling (no DivideByZero, displays "0.0%"). +/// Unavailable snapshot renders em dashes plus the error message. +/// Tile clicks navigate to the correct pre-filtered Audit Log URL. +/// +/// +public class AuditKpiTilesTests : BunitContext +{ + private static AuditLogKpiSnapshot MakeSnapshot(long total, long errors, long backlog) => + new(total, errors, backlog, new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)); + + [Fact] + public void Renders_ThreeTiles_FromSnapshot() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(total: 120, errors: 3, backlog: 7)) + .Add(c => c.IsAvailable, true)); + + // Three stable data-test selectors — these are the contract for both + // tests and any future Playwright sweep. + Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup); + Assert.Contains("data-test=\"audit-kpi-error-rate\"", cut.Markup); + Assert.Contains("data-test=\"audit-kpi-backlog\"", cut.Markup); + + // Tile values render the snapshot's counters. + Assert.Contains("120", cut.Markup); // volume + Assert.Contains("7", cut.Markup); // backlog + } + + [Fact] + public void ErrorRate_Computed_From_Total_AndErrors() + { + // 5 errors out of 100 → 5.0%. + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 5, backlog: 0)) + .Add(c => c.IsAvailable, true)); + + Assert.Contains("5.0%", cut.Markup); + } + + [Fact] + public void ZeroEvents_DoesNotDivideByZero_RendersZeroPercent() + { + // Total = 0 → naïve division would throw or yield NaN. The tile must + // render "0.0%" instead (zero events means zero errors too — a real + // signal, not an unavailability marker). + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(total: 0, errors: 0, backlog: 0)) + .Add(c => c.IsAvailable, true)); + + Assert.Contains("0.0%", cut.Markup); + // And the volume tile shows "0", not an em dash — the snapshot itself + // is available; the system was just quiet for the hour. + Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup); + } + + [Fact] + public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage() + { + var cut = Render(p => p + .Add(c => c.Snapshot, (AuditLogKpiSnapshot?)null) + .Add(c => c.IsAvailable, false) + .Add(c => c.ErrorMessage, "DB connection refused")); + + // All three tiles show em dashes — em dash (U+2014) "—" must appear. + Assert.Contains("—", cut.Markup); + // Inline error message renders below. + Assert.Contains("Audit KPIs unavailable", cut.Markup); + Assert.Contains("DB connection refused", cut.Markup); + } + + [Fact] + public void ErrorRateTile_Click_NavigatesToAuditLog_WithFailedStatusFilter() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 3, backlog: 0)) + .Add(c => c.IsAvailable, true)); + + // bUnit's BunitNavigationManager records the last URI a Navigation.NavigateTo call hit. + var nav = (BunitNavigationManager)Services.GetRequiredService(); + + var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]"); + tile.Click(); + + // Spec: error-rate tile drills into ?status=Failed. + Assert.Contains("/audit/log?status=Failed", nav.Uri); + } + + [Fact] + public void VolumeTile_Click_NavigatesToUnfilteredAuditLog() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 3, backlog: 0)) + .Add(c => c.IsAvailable, true)); + + var nav = (BunitNavigationManager)Services.GetRequiredService(); + var tile = cut.Find("[data-test=\"audit-kpi-volume\"]"); + tile.Click(); + + // Unfiltered /audit/log — no query string. + Assert.EndsWith("/audit/log", nav.Uri); + } + + [Fact] + public void BacklogTile_Click_NavigatesToAuditLog() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 0, backlog: 12)) + .Add(c => c.IsAvailable, true)); + + var nav = (BunitNavigationManager)Services.GetRequiredService(); + var tile = cut.Find("[data-test=\"audit-kpi-backlog\"]"); + tile.Click(); + + Assert.EndsWith("/audit/log", nav.Uri); + } + + [Fact] + public void NonzeroErrorRate_GetsWarningBorder_NotDangerBelowTenPercent() + { + // 5% is < 10% → warning border, not danger. + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 5, backlog: 0)) + .Add(c => c.IsAvailable, true)); + + var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]"); + Assert.Contains("border-warning", tile.GetAttribute("class") ?? string.Empty); + Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty); + } + + [Fact] + public void HighErrorRate_GetsDangerBorder() + { + // 25% is > 10% → danger border. + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 25, backlog: 0)) + .Add(c => c.IsAvailable, true)); + + var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]"); + Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs index 9852eb9..01de979 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs @@ -191,6 +191,44 @@ public class AuditLogPageScaffoldTests : BunitContext }); } + [Fact] + public void NavigateWithStatusParam_AppliesStatusFilter() + { + // Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills + // in with ?status=Failed. The page parses the enum (case-insensitive), + // builds an AuditLogQueryFilter with Status set, and auto-loads. + _queryService = Substitute.For(); + _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new List())); + + var cut = RenderAuditLogPageWithQuery("status=Failed", "Admin"); + + cut.WaitForAssertion(() => + { + _queryService.Received().QueryAsync( + Arg.Is(f => f.Status == AuditStatus.Failed), + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void NavigateWithUnknownStatusParam_IsSilentlyDropped_NoAutoLoad() + { + _queryService = Substitute.For(); + + var cut = RenderAuditLogPageWithQuery("status=NotARealStatus", "Admin"); + + // An unparseable status value leaves Status null. With no other filter + // params present the page renders but does NOT call the query service + // (matching the existing "no params" contract). + cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup)); + _queryService.DidNotReceive().QueryAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + [Fact] public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad() { diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs index a78b9b0..78dbd52 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs @@ -6,9 +6,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; +using ScadaLink.CentralUI.Services; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Messages.Notification; +using ScadaLink.Commons.Types; using ScadaLink.Communication; using ScadaLink.HealthMonitoring; using HealthPage = ScadaLink.CentralUI.Components.Pages.Monitoring.Health; @@ -55,6 +57,16 @@ public class HealthPageTests : BunitContext .Returns(Task.FromResult>(new List())); Services.AddSingleton(siteRepo); + // Audit Log (#23) M7 Bundle E — the Health page now also fetches the + // Audit KPI snapshot. Stub it with an empty point-in-time reading so + // the existing assertions (Notification Outbox tiles, Online/Offline + // counts) keep passing; tests that target the Audit tiles set their + // own substitute. + var auditService = Substitute.For(); + auditService.GetKpiSnapshotAsync(Arg.Any()) + .Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow))); + Services.AddSingleton(auditService); + var claims = new[] { new Claim("Username", "tester"), @@ -92,6 +104,35 @@ public class HealthPageTests : BunitContext Assert.Contains("View details", link.TextContent); } + [Fact] + public void Renders_AuditKpiTiles_WithValues() + { + // Override the default empty snapshot — this test wants concrete values + // to land in the three Audit tiles. + var auditService = Substitute.For(); + auditService.GetKpiSnapshotAsync(Arg.Any()) + .Returns(Task.FromResult(new AuditLogKpiSnapshot( + TotalEventsLastHour: 250, + ErrorEventsLastHour: 5, + BacklogTotal: 17, + AsOfUtc: DateTime.UtcNow))); + Services.AddSingleton(auditService); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + // The three audit tiles render at the documented data-test selectors. + Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup); + Assert.Contains("data-test=\"audit-kpi-error-rate\"", cut.Markup); + Assert.Contains("data-test=\"audit-kpi-backlog\"", cut.Markup); + // Volume shows the formatted thousand-separator value. + Assert.Contains("250", cut.Markup); + // Backlog renders 17. + Assert.Contains("17", cut.Markup); + }); + } + [Fact] public void OutboxKpiFailure_ShowsGracefulFallback() { diff --git a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs index 97743bf..181f3bc 100644 --- a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs @@ -2,8 +2,11 @@ using NSubstitute; using ScadaLink.CentralUI.Services; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Messages.Health; +using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Audit; using ScadaLink.Commons.Types.Enums; +using ScadaLink.HealthMonitoring; namespace ScadaLink.CentralUI.Tests.Services; @@ -15,6 +18,13 @@ namespace ScadaLink.CentralUI.Tests.Services; /// public class AuditLogQueryServiceTests { + private static ICentralHealthAggregator EmptyAggregator() + { + var agg = Substitute.For(); + agg.GetAllSiteStates().Returns(new Dictionary()); + return agg; + } + [Fact] public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository() { @@ -28,7 +38,7 @@ public class AuditLogQueryServiceTests repo.QueryAsync(filter, paging, Arg.Any()) .Returns(Task.FromResult>(expected)); - var sut = new AuditLogQueryService(repo); + var sut = new AuditLogQueryService(repo, EmptyAggregator()); var result = await sut.QueryAsync(filter, paging); @@ -44,7 +54,7 @@ public class AuditLogQueryServiceTests repo.QueryAsync(Arg.Any(), Arg.Do(p => observed = p), Arg.Any()) .Returns(Task.FromResult>(Array.Empty())); - var sut = new AuditLogQueryService(repo); + var sut = new AuditLogQueryService(repo, EmptyAggregator()); await sut.QueryAsync(new AuditLogQueryFilter(), paging: null); @@ -54,4 +64,103 @@ public class AuditLogQueryServiceTests Assert.Null(observed.AfterOccurredAtUtc); Assert.Null(observed.AfterEventId); } + + // ───────────────────────────────────────────────────────────────────────── + // M7-T13 Bundle E: GetKpiSnapshotAsync — composes repo + health-aggregator + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetKpiSnapshotAsync_ForwardsToRepo_AddsBacklogFromHealthAggregator() + { + var repo = Substitute.For(); + var anchor = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc); + var repoSnapshot = new AuditLogKpiSnapshot( + TotalEventsLastHour: 42, + ErrorEventsLastHour: 7, + BacklogTotal: 0, // repo leaves this at zero + AsOfUtc: anchor); + repo.GetKpiSnapshotAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(repoSnapshot)); + + // Two sites: plant-a with PendingCount=5, plant-b with PendingCount=11. + // Sum = 16 → backlog tile shows 16. + var sites = new Dictionary + { + ["plant-a"] = StateWithBacklog("plant-a", pending: 5), + ["plant-b"] = StateWithBacklog("plant-b", pending: 11), + }; + var agg = Substitute.For(); + agg.GetAllSiteStates().Returns(sites); + + var sut = new AuditLogQueryService(repo, agg); + + var snapshot = await sut.GetKpiSnapshotAsync(); + + Assert.Equal(42, snapshot.TotalEventsLastHour); + Assert.Equal(7, snapshot.ErrorEventsLastHour); + Assert.Equal(16, snapshot.BacklogTotal); + Assert.Equal(anchor, snapshot.AsOfUtc); + + // The service requests a 1-hour trailing window and lets the repo + // anchor nowUtc to its own clock — we leave the second parameter null. + await repo.Received(1).GetKpiSnapshotAsync( + TimeSpan.FromHours(1), + Arg.Is(v => v == null), + Arg.Any()); + } + + [Fact] + public async Task GetKpiSnapshotAsync_SiteWithoutBacklogSnapshot_ContributesZero() + { + var repo = Substitute.For(); + repo.GetKpiSnapshotAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow))); + + // plant-a has no LatestReport at all; plant-b has a report but null SiteAuditBacklog. + var sites = new Dictionary + { + ["plant-a"] = new() { SiteId = "plant-a", LatestReport = null, IsOnline = true }, + ["plant-b"] = StateWithBacklog("plant-b", pending: null), + ["plant-c"] = StateWithBacklog("plant-c", pending: 4), + }; + var agg = Substitute.For(); + agg.GetAllSiteStates().Returns(sites); + + var sut = new AuditLogQueryService(repo, agg); + + var snapshot = await sut.GetKpiSnapshotAsync(); + + // Only plant-c contributes; plant-a (no report) and plant-b (null backlog) yield zero. + Assert.Equal(4, snapshot.BacklogTotal); + } + + private static SiteHealthState StateWithBacklog(string siteId, int? pending) + { + SiteAuditBacklogSnapshot? backlog = pending.HasValue + ? new SiteAuditBacklogSnapshot(pending.Value, OldestPendingUtc: null, OnDiskBytes: 0) + : null; + var report = new SiteHealthReport( + SiteId: siteId, + SequenceNumber: 1, + ReportTimestamp: DateTimeOffset.UtcNow, + DataConnectionStatuses: new Dictionary(), + TagResolutionCounts: new Dictionary(), + ScriptErrorCount: 0, + AlarmEvaluationErrorCount: 0, + StoreAndForwardBufferDepths: new Dictionary(), + DeadLetterCount: 0, + DeployedInstanceCount: 0, + EnabledInstanceCount: 0, + DisabledInstanceCount: 0, + SiteAuditBacklog: backlog); + return new SiteHealthState + { + SiteId = siteId, + LatestReport = report, + LastReportReceivedAt = DateTimeOffset.UtcNow, + LastHeartbeatAt = DateTimeOffset.UtcNow, + LastSequenceNumber = 1, + IsOnline = true, + }; + } } diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs index df1daeb..775fb2e 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs @@ -510,6 +510,82 @@ public class AuditLogRepositoryTests : IClassFixture Assert.DoesNotContain(new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), boundaries); } + // ------------------------------------------------------------------------ + // M7-T13 Bundle E: GetKpiSnapshotAsync — Health-dashboard Audit KPI tiles + // ------------------------------------------------------------------------ + // + // The dashboard's "Audit volume" tile reads TotalEventsLastHour and the + // "Audit error rate" tile reads ErrorEventsLastHour / TotalEventsLastHour. + // The repository must (a) count rows whose OccurredAtUtc falls in + // [nowUtc - window, nowUtc] and (b) within that scope count rows whose + // Status ∈ {Failed, Parked, Discarded} as "error". BacklogTotal is left at + // zero here — the service layer composes it in from the health aggregator. + // + // To keep the test deterministic against the shared fixture DB, each test + // pins an obscure-distant nowUtc and seeds rows with OccurredAtUtc inside a + // narrow band centred on that anchor — no other test in this class seeds + // there, so the global count equals the seeded count for that band. + + [SkippableFact] + public async Task GetKpiSnapshotAsync_WithMixedStatusRows_ReturnsCorrectTotalsAndErrors() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // Anchor in November 2026 — no other test in this class seeds there. + var nowUtc = new DateTime(2026, 11, 20, 10, 0, 0, DateTimeKind.Utc); + // Seed 3 success + 1 Failed + 1 Parked + 1 Discarded inside the trailing + // 1h window; plus 1 row outside the window that must be excluded. + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-5), status: AuditStatus.Delivered)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-10), status: AuditStatus.Delivered)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-15), status: AuditStatus.Delivered)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-20), status: AuditStatus.Failed)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-25), status: AuditStatus.Parked)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-30), status: AuditStatus.Discarded)); + // Outside-window row (2h before nowUtc). + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddHours(-2), status: AuditStatus.Failed)); + // Submitted is in-flight, not an "error" — must NOT count toward errors. + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-2), status: AuditStatus.Submitted)); + + var snapshot = await repo.GetKpiSnapshotAsync( + window: TimeSpan.FromHours(1), + nowUtc: nowUtc); + + // 7 rows fall in the trailing 1h window (3 Delivered + 1 Failed + 1 Parked + 1 Discarded + 1 Submitted). + // The 2h-before-nowUtc Failed row is excluded by the window. + Assert.Equal(7, snapshot.TotalEventsLastHour); + // Only Failed/Parked/Discarded count as errors → 3. + Assert.Equal(3, snapshot.ErrorEventsLastHour); + // The service layer fills BacklogTotal; the repo leaves it at 0. + Assert.Equal(0, snapshot.BacklogTotal); + // AsOfUtc echoes the anchor. + Assert.Equal(nowUtc, snapshot.AsOfUtc); + } + + [SkippableFact] + public async Task GetKpiSnapshotAsync_EmptyWindow_ReturnsZeroTotals() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // Anchor in December 2026 — no test seeds there, so the window is empty. + var nowUtc = new DateTime(2026, 12, 20, 10, 0, 0, DateTimeKind.Utc); + + var snapshot = await repo.GetKpiSnapshotAsync( + window: TimeSpan.FromMinutes(1), + nowUtc: nowUtc); + + Assert.Equal(0, snapshot.TotalEventsLastHour); + Assert.Equal(0, snapshot.ErrorEventsLastHour); + Assert.Equal(0, snapshot.BacklogTotal); + Assert.Equal(nowUtc, snapshot.AsOfUtc); + } + private async Task ScalarAsync(ScadaLinkDbContext context, string sql) { var conn = context.Database.GetDbConnection(); From 8744630adbca4a2d0519b4b073c407d4534b2bb2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 20:57:01 -0400 Subject: [PATCH 11/14] feat(ui): server-side streaming CSV export of Audit Log (#23 M7) --- .../Audit/AuditExportEndpoints.cs | 170 ++++++++++ .../Components/Pages/Audit/AuditLogPage.razor | 16 + .../Pages/Audit/AuditLogPage.razor.cs | 67 ++++ src/ScadaLink.CentralUI/EndpointExtensions.cs | 2 + .../ServiceCollectionExtensions.cs | 4 + .../Services/AuditLogExportService.cs | 238 ++++++++++++++ .../Audit/AuditExportEndpointsTests.cs | 278 ++++++++++++++++ .../Pages/AuditLogPageExportUrlTests.cs | 77 +++++ .../ScadaLink.CentralUI.Tests.csproj | 1 + .../Services/AuditLogExportServiceTests.cs | 310 ++++++++++++++++++ 10 files changed, 1163 insertions(+) create mode 100644 src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs create mode 100644 src/ScadaLink.CentralUI/Services/AuditLogExportService.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Services/AuditLogExportServiceTests.cs diff --git a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs new file mode 100644 index 0000000..66ed746 --- /dev/null +++ b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs @@ -0,0 +1,170 @@ +using System.Globalization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.CentralUI.Services; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.Security; + +namespace ScadaLink.CentralUI.Audit; + +/// +/// Minimal-API endpoint hosting the Audit Log CSV export (#23 M7-T14 / Bundle F). +/// +/// +/// CentralUI ships no MVC controllers (see +/// and ), +/// so the brief's "controller" is implemented as a minimal-API endpoint instead. +/// The endpoint streams to Response.Body directly so the export does NOT +/// buffer the full result set in memory — see +/// . +/// +/// +/// +/// The route is admin-gated to mirror the NavMenu (RequireAdmin wraps +/// the Audit section). The query-string parser silently drops unrecognised +/// values to match the page-level parser in +/// AuditLogPage.ApplyQueryStringFilters — an unknown enum value yields +/// the same "no constraint" outcome rather than a 400. +/// +/// +public static class AuditExportEndpoints +{ + /// + /// Default row cap for a single export. Large enough to satisfy realistic + /// operator workflows; mirrors the brief's recommended ceiling. Operators + /// who need more should fall back to the CLI (footnote rendered in the + /// cap-footer line). + /// + public const int DefaultMaxRows = 100_000; + + public static IEndpointRouteBuilder MapAuditExportEndpoints(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("/api/centralui/audit/export", HandleExportAsync) + .RequireAuthorization(AuthorizationPolicies.RequireAdmin); + + return endpoints; + } + + /// + /// Handles GET /api/centralui/audit/export. Internal so endpoint + /// tests can call it directly when desirable; the live wire-up goes + /// through the minimal-API map above. + /// + internal static async Task HandleExportAsync(HttpContext context, IAuditLogExportService exportService) + { + var filter = ParseFilter(context.Request.Query); + var maxRows = ParseMaxRows(context.Request.Query); + + // Stamp the response headers BEFORE the first body write so the client + // sees text/csv + an attachment download right away. + var fileName = $"audit-log-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv"; + context.Response.ContentType = "text/csv; charset=utf-8"; + context.Response.Headers["Content-Disposition"] = $"attachment; filename=\"{fileName}\""; + // Defeat any intermediate buffering proxy so the operator sees rows + // streaming through as the server flushes each repository page. + context.Response.Headers["Cache-Control"] = "no-store"; + + await exportService.ExportAsync(filter, maxRows, context.Response.Body, context.RequestAborted); + } + + /// + /// Parses the query-string into an . + /// Unknown enum names / un-parseable Guids / dates are silently dropped + /// (same contract as AuditLogPage.ApplyQueryStringFilters). + /// + internal static AuditLogQueryFilter ParseFilter(IQueryCollection query) + { + AuditChannel? channel = null; + if (query.TryGetValue("channel", out var channelValues) + && Enum.TryParse(channelValues.ToString(), ignoreCase: true, out var parsedChannel)) + { + channel = parsedChannel; + } + + AuditKind? kind = null; + if (query.TryGetValue("kind", out var kindValues) + && Enum.TryParse(kindValues.ToString(), ignoreCase: true, out var parsedKind)) + { + kind = parsedKind; + } + + AuditStatus? status = null; + if (query.TryGetValue("status", out var statusValues) + && Enum.TryParse(statusValues.ToString(), ignoreCase: true, out var parsedStatus)) + { + status = parsedStatus; + } + + string? site = TrimToNullable(query, "site"); + string? target = TrimToNullable(query, "target"); + string? actor = TrimToNullable(query, "actor"); + + Guid? correlationId = null; + if (query.TryGetValue("correlationId", out var corrValues) + && Guid.TryParse(corrValues.ToString(), out var parsedCorr)) + { + correlationId = parsedCorr; + } + + DateTime? fromUtc = ParseUtcDate(query, "from"); + DateTime? toUtc = ParseUtcDate(query, "to"); + + return new AuditLogQueryFilter( + Channel: channel, + Kind: kind, + Status: status, + SourceSiteId: site, + Target: target, + Actor: actor, + CorrelationId: correlationId, + FromUtc: fromUtc, + ToUtc: toUtc); + } + + /// + /// Optional maxRows= query-string override. Falls back to + /// on a missing / non-positive / unparseable + /// value rather than erroring — same lax contract as the rest of the + /// query parser. + /// + private static int ParseMaxRows(IQueryCollection query) + { + if (query.TryGetValue("maxRows", out var raw) + && int.TryParse(raw.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + && parsed > 0) + { + return parsed; + } + return DefaultMaxRows; + } + + private static string? TrimToNullable(IQueryCollection query, string key) + { + if (!query.TryGetValue(key, out var values)) + { + return null; + } + var v = values.ToString(); + return string.IsNullOrWhiteSpace(v) ? null : v.Trim(); + } + + private static DateTime? ParseUtcDate(IQueryCollection query, string key) + { + if (!query.TryGetValue(key, out var values)) + { + return null; + } + if (DateTime.TryParse( + values.ToString(), + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var parsed)) + { + return DateTime.SpecifyKind(parsed, DateTimeKind.Utc); + } + return null; + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor index 1aadb03..2b48fa0 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor @@ -19,6 +19,22 @@ InitialInstanceSearch="@_initialInstanceSearch" />
+ @* Export button (Bundle F / M7-T14). A plain link triggers the + streaming CSV endpoint at /api/centralui/audit/export — chosen over a + SignalR-driven download because the request can stream 100k rows directly + to the response body without buffering through the Blazor circuit. The + href reflects the most recently applied filter; before Apply is clicked, + an unconstrained export is exposed. *@ + + @* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's drilldown drawer; the grid stays in "no events" mode until the user applies a filter so the page does not auto-load the full audit table on first render. *@ diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs index bc26789..b3c05ff 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.WebUtilities; using ScadaLink.Commons.Entities.Audit; @@ -158,4 +159,70 @@ public partial class AuditLogPage // grid) shows the same row instantly without a re-render flicker. _drawerOpen = false; } + + /// + /// Bundle F (M7-T14): URL the Export-CSV link points at. Renders the most + /// recently applied filter as query-string params so the server-side + /// streaming endpoint reproduces the user's current view. With no filter + /// applied yet, returns the bare endpoint — i.e. an unconstrained export. + /// + /// + /// Built here rather than in markup so the per-row test coverage can + /// exercise the URL composition without booting the full Blazor renderer. + /// + internal string ExportUrl => BuildExportUrl(_currentFilter); + + internal static string BuildExportUrl(AuditLogQueryFilter? filter) + { + const string basePath = "/api/centralui/audit/export"; + if (filter is null) + { + return basePath; + } + + var parts = new List>(9); + if (filter.Channel is { } ch) + { + parts.Add(new("channel", ch.ToString())); + } + if (filter.Kind is { } kind) + { + parts.Add(new("kind", kind.ToString())); + } + if (filter.Status is { } status) + { + parts.Add(new("status", status.ToString())); + } + if (!string.IsNullOrWhiteSpace(filter.SourceSiteId)) + { + parts.Add(new("site", filter.SourceSiteId)); + } + if (!string.IsNullOrWhiteSpace(filter.Target)) + { + parts.Add(new("target", filter.Target)); + } + if (!string.IsNullOrWhiteSpace(filter.Actor)) + { + parts.Add(new("actor", filter.Actor)); + } + if (filter.CorrelationId is { } corr) + { + parts.Add(new("correlationId", corr.ToString())); + } + if (filter.FromUtc is { } from) + { + parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture))); + } + if (filter.ToUtc is { } to) + { + parts.Add(new("to", to.ToString("O", CultureInfo.InvariantCulture))); + } + + if (parts.Count == 0) + { + return basePath; + } + + return QueryHelpers.AddQueryString(basePath, parts); + } } diff --git a/src/ScadaLink.CentralUI/EndpointExtensions.cs b/src/ScadaLink.CentralUI/EndpointExtensions.cs index ff4062f..608e62a 100644 --- a/src/ScadaLink.CentralUI/EndpointExtensions.cs +++ b/src/ScadaLink.CentralUI/EndpointExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; +using ScadaLink.CentralUI.Audit; using ScadaLink.CentralUI.Auth; using ScadaLink.CentralUI.Components.Layout; using ScadaLink.CentralUI.ScriptAnalysis; @@ -17,6 +18,7 @@ public static class EndpointExtensions { endpoints.MapAuthEndpoints(); endpoints.MapScriptAnalysisEndpoints(); + endpoints.MapAuditExportEndpoints(); endpoints.MapRazorComponents() .AddInteractiveServerRenderMode() diff --git a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs index be2310b..14d59ba 100644 --- a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs @@ -32,6 +32,10 @@ public static class ServiceCollectionExtensions // results grid can be tested with a stubbed query source. services.AddScoped(); + // Audit Log (#23 M7-T14 / Bundle F): server-side streaming CSV export. + // Backs the Audit Log page's Export button via GET /api/centralui/audit/export. + services.AddScoped(); + // Roslyn-backed C# analysis for the Monaco script editor. // Scoped because SharedScriptCatalog wraps a scoped service. services.AddMemoryCache(o => o.SizeLimit = 200); diff --git a/src/ScadaLink.CentralUI/Services/AuditLogExportService.cs b/src/ScadaLink.CentralUI/Services/AuditLogExportService.cs new file mode 100644 index 0000000..dbbbd25 --- /dev/null +++ b/src/ScadaLink.CentralUI/Services/AuditLogExportService.cs @@ -0,0 +1,238 @@ +using System.Globalization; +using System.Text; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Types.Audit; + +namespace ScadaLink.CentralUI.Services; + +/// +/// Streaming CSV exporter for the Audit Log page (#23 M7-T14 / Bundle F). +/// +/// +/// The exporter iterates page by page +/// using its keyset cursor and writes each row to a destination +/// as RFC 4180-compliant CSV. The output is flushed after +/// each page so a large export starts streaming to the client immediately +/// instead of buffering the whole result set in memory. +/// +/// +/// +/// Output is capped at a caller-supplied maxRows ceiling; when the cap +/// is hit the service appends a # Capped at … rows. Use the CLI for larger +/// exports. footer line so an operator can tell a truncated download from +/// a complete one. The header row contains the 21 columns of +/// in declaration order. +/// +/// +public interface IAuditLogExportService +{ + /// + /// Streams a CSV export of the rows matching to + /// , capping at . + /// + /// Repository filter to apply. + /// + /// Maximum number of data rows (excluding header / footer) to emit. The + /// service stops paging once this is reached and appends a cap footer. + /// + /// Destination stream — typically the HTTP response body. + /// Cancellation token (e.g. HttpContext.RequestAborted). + /// + /// Optional override for the repository page size. Defaults to 1000 — large + /// enough to amortise the per-query overhead, small enough that one page in + /// memory is bounded. + /// + Task ExportAsync( + AuditLogQueryFilter filter, + int maxRows, + Stream output, + CancellationToken ct, + int pageSize = AuditLogExportService.DefaultPageSize); +} + +/// +public sealed class AuditLogExportService : IAuditLogExportService +{ + /// Default rows pulled per repository round-trip. + public const int DefaultPageSize = 1000; + + private readonly IAuditLogRepository _repository; + + public AuditLogExportService(IAuditLogRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + /// + public async Task ExportAsync( + AuditLogQueryFilter filter, + int maxRows, + Stream output, + CancellationToken ct, + int pageSize = DefaultPageSize) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(output); + if (maxRows <= 0) throw new ArgumentOutOfRangeException(nameof(maxRows), "maxRows must be positive."); + if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize), "pageSize must be positive."); + + // UTF-8 no-BOM: Excel will still recognise the CSV but the file stays + // a clean ASCII-superset for downstream pipes / grep. The StreamWriter + // leaves the underlying stream open so the controller can decide when + // to dispose / complete it. + await using var writer = new StreamWriter( + output, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + bufferSize: 4096, + leaveOpen: true); + writer.NewLine = "\r\n"; // RFC 4180 + + // Header — 21 columns in AuditEvent declaration order. + await writer.WriteLineAsync(Header); + + int written = 0; + AuditLogPaging cursor = new(PageSize: Math.Min(pageSize, maxRows)); + + while (written < maxRows) + { + // Honour cancellation BEFORE we kick off another round-trip — this + // is the deterministic cancellation point that the test pins on. + ct.ThrowIfCancellationRequested(); + + // Tighten the last page's size so we never pull more than the cap. + var remaining = maxRows - written; + var effectivePageSize = Math.Min(cursor.PageSize, remaining); + var pageCursor = cursor with { PageSize = effectivePageSize }; + + var page = await _repository.QueryAsync(filter, pageCursor, ct); + if (page.Count == 0) + { + break; + } + + foreach (var evt in page) + { + if (written >= maxRows) + { + break; + } + await writer.WriteLineAsync(FormatCsvRow(evt)); + written++; + } + + // Push bytes through the StreamWriter buffer into the underlying + // stream so the client sees progress per-page instead of waiting + // for the full export to buffer up. + await writer.FlushAsync(ct); + await output.FlushAsync(ct); + + // Last page (short read) — no more data to fetch. + if (page.Count < effectivePageSize) + { + break; + } + + var last = page[^1]; + cursor = new AuditLogPaging( + PageSize: pageSize, + AfterOccurredAtUtc: last.OccurredAtUtc, + AfterEventId: last.EventId); + } + + if (written >= maxRows) + { + // Cap footer — visible to operators so a truncated download is + // distinguishable from a complete one. The "#" prefix keeps it + // out of the data columns; spreadsheet tools will surface it as + // a single-cell row. + await writer.WriteLineAsync( + $"# Capped at {maxRows.ToString(CultureInfo.InvariantCulture)} rows. Use the CLI for larger exports."); + await writer.FlushAsync(ct); + await output.FlushAsync(ct); + } + } + + // ───────────────────────────────────────────────────────────────────── + // CSV helpers + // ───────────────────────────────────────────────────────────────────── + + /// The 21 column names in declaration order. + internal const string Header = + "EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId," + + "SourceSiteId,SourceInstanceId,SourceScript,Actor,Target,Status," + + "HttpStatus,DurationMs,ErrorMessage,ErrorDetail,RequestSummary," + + "ResponseSummary,PayloadTruncated,Extra,ForwardState"; + + /// + /// Serialises one as a CSV row (no trailing newline). + /// Each nullable column renders as the empty string when null; non-null + /// scalars use invariant culture so an export taken on one locale parses + /// cleanly on another. + /// + internal static string FormatCsvRow(AuditEvent evt) + { + var sb = new StringBuilder(256); + AppendField(sb, evt.EventId.ToString(), first: true); + AppendField(sb, FormatDate(evt.OccurredAtUtc)); + AppendField(sb, FormatDate(evt.IngestedAtUtc)); + AppendField(sb, evt.Channel.ToString()); + AppendField(sb, evt.Kind.ToString()); + AppendField(sb, evt.CorrelationId?.ToString()); + AppendField(sb, evt.SourceSiteId); + AppendField(sb, evt.SourceInstanceId); + AppendField(sb, evt.SourceScript); + AppendField(sb, evt.Actor); + AppendField(sb, evt.Target); + AppendField(sb, evt.Status.ToString()); + AppendField(sb, evt.HttpStatus?.ToString(CultureInfo.InvariantCulture)); + AppendField(sb, evt.DurationMs?.ToString(CultureInfo.InvariantCulture)); + AppendField(sb, evt.ErrorMessage); + AppendField(sb, evt.ErrorDetail); + AppendField(sb, evt.RequestSummary); + AppendField(sb, evt.ResponseSummary); + AppendField(sb, evt.PayloadTruncated.ToString()); + AppendField(sb, evt.Extra); + AppendField(sb, evt.ForwardState?.ToString()); + return sb.ToString(); + } + + private static string? FormatDate(DateTime? value) => + value?.ToString("O", CultureInfo.InvariantCulture); + + private static string FormatDate(DateTime value) => + value.ToString("O", CultureInfo.InvariantCulture); + + private static void AppendField(StringBuilder sb, string? value, bool first = false) + { + if (!first) sb.Append(','); + if (string.IsNullOrEmpty(value)) + { + return; + } + + // RFC 4180: quote on comma / quote / CR / LF; double-up embedded quotes. + bool needsQuoting = value.IndexOfAny(s_quoteTriggers) >= 0; + if (!needsQuoting) + { + sb.Append(value); + return; + } + + sb.Append('"'); + foreach (var ch in value) + { + if (ch == '"') + { + sb.Append('"').Append('"'); + } + else + { + sb.Append(ch); + } + } + sb.Append('"'); + } + + private static readonly char[] s_quoteTriggers = { ',', '"', '\r', '\n' }; +} diff --git a/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs b/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs new file mode 100644 index 0000000..fcb01f4 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs @@ -0,0 +1,278 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using ScadaLink.CentralUI.Audit; +using ScadaLink.CentralUI.Services; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.Security; + +namespace ScadaLink.CentralUI.Tests.Audit; + +/// +/// Endpoint-level tests for the Audit Log CSV export (#23 M7-T14 / Bundle F). +/// +/// +/// CentralUI uses minimal-API endpoints (see AuthEndpoints / +/// ScriptAnalysisEndpoints) rather than MVC controllers, so this brief's +/// "controller" is implemented as . The tests +/// pin two things: (a) the GET /api/centralui/audit/export route sets +/// the correct content-type + attachment disposition + body, and (b) the +/// query-string is parsed into an and handed +/// to . +/// +/// +public class AuditExportEndpointsTests +{ + private static AuditEvent SampleEvent() => new() + { + EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"), + OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + IngestedAtUtc = null, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + SourceSiteId = "plant-a", + Status = AuditStatus.Delivered, + HttpStatus = 200, + }; + + /// + /// Builds a tiny in-process test host that wires the export endpoint to a + /// stubbed . Returns a ready-to-call + /// and the repo substitute so the test can assert + /// on what the endpoint did. + /// + private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync() + { + var repo = Substitute.For(); + repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns( + Task.FromResult>(new[] { SampleEvent() }), + Task.FromResult>(Array.Empty())); + + var hostBuilder = new HostBuilder() + .ConfigureWebHost(web => + { + web.UseTestServer(); + web.ConfigureServices(services => + { + services.AddRouting(); + // The endpoint is admin-gated; the tests run as + // pre-authenticated principals built by FakeAuthHandler + // (everyone has the Admin role) so the RequireAdmin policy + // succeeds. + services.AddAuthentication(FakeAuthHandler.SchemeName) + .AddScheme( + FakeAuthHandler.SchemeName, _ => { }); + services.AddAuthorization(options => + { + options.AddPolicy(AuthorizationPolicies.RequireAdmin, policy => + policy.RequireClaim(JwtTokenService.RoleClaimType, "Admin")); + }); + services.AddSingleton(repo); + services.AddScoped(); + }); + web.Configure(app => + { + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseEndpoints(endpoints => + { + endpoints.MapAuditExportEndpoints(); + }); + }); + }); + + var host = await hostBuilder.StartAsync(); + var client = host.GetTestClient(); + return (client, repo, host); + } + + [Fact] + public async Task ExportEndpoint_Get_ReturnsCsvContentType_AndAttachmentDisposition() + { + var (client, _, host) = await BuildHostAsync(); + using (host) + { + var response = await client.GetAsync("/api/centralui/audit/export"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Content-Type: text/csv (charset may or may not be present). + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType); + + // Content-Disposition: attachment with a *.csv filename. + ContentDispositionHeaderValue? disposition = response.Content.Headers.ContentDisposition; + Assert.NotNull(disposition); + Assert.Equal("attachment", disposition!.DispositionType); + Assert.NotNull(disposition.FileName); + Assert.EndsWith(".csv", disposition.FileName, StringComparison.OrdinalIgnoreCase); + + // Body starts with the header row and contains the sample row. + var body = await response.Content.ReadAsStringAsync(); + Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,", body); + Assert.Contains("11111111-1111-1111-1111-111111111111", body); + } + } + + [Fact] + public async Task ExportEndpoint_PassesFilterFromQueryString_ToService() + { + var (client, repo, host) = await BuildHostAsync(); + using (host) + { + var correlationId = Guid.NewGuid().ToString(); + var url = + "/api/centralui/audit/export?" + + "channel=ApiOutbound&" + + "kind=ApiCall&" + + "status=Failed&" + + "site=plant-a&" + + "target=PaymentApi&" + + "actor=apikey-1&" + + $"correlationId={correlationId}&" + + "from=2026-05-20T00:00:00Z&" + + "to=2026-05-20T23:59:59Z"; + + var response = await client.GetAsync(url); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Read the body to ensure the streaming response is fully drained + // before we assert on the repo substitute (the test server flushes + // the endpoint pipeline on response read). + _ = await response.Content.ReadAsStringAsync(); + + await repo.Received().QueryAsync( + Arg.Is(f => + f.Channel == AuditChannel.ApiOutbound && + f.Kind == AuditKind.ApiCall && + f.Status == AuditStatus.Failed && + f.SourceSiteId == "plant-a" && + f.Target == "PaymentApi" && + f.Actor == "apikey-1" && + f.CorrelationId == Guid.Parse(correlationId) && + f.FromUtc == new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc) && + f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)), + Arg.Any(), + Arg.Any()); + } + } + + [Fact] + public async Task ExportEndpoint_NoQueryString_PassesEmptyFilter() + { + // Sanity: a bare GET (no params) yields a filter with every column null + // — i.e. an unconstrained export. + var (client, repo, host) = await BuildHostAsync(); + using (host) + { + var response = await client.GetAsync("/api/centralui/audit/export"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _ = await response.Content.ReadAsStringAsync(); + + await repo.Received().QueryAsync( + Arg.Is(f => + f.Channel == null && + f.Kind == null && + f.Status == null && + f.SourceSiteId == null && + f.Target == null && + f.Actor == null && + f.CorrelationId == null && + f.FromUtc == null && + f.ToUtc == null), + Arg.Any(), + Arg.Any()); + } + } + + [Fact] + public async Task ExportEndpoint_UnknownEnumValue_SilentlyIgnored() + { + // Defensive parsing: a junk channel value MUST NOT 500 the export — + // mirrors the page-level query-string parser (#23 M7 Bundle D) which + // silently drops unrecognised values. + var (client, repo, host) = await BuildHostAsync(); + using (host) + { + var response = await client.GetAsync("/api/centralui/audit/export?channel=DefinitelyNotAChannel"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _ = await response.Content.ReadAsStringAsync(); + + await repo.Received().QueryAsync( + Arg.Is(f => f.Channel == null), + Arg.Any(), + Arg.Any()); + } + } + + /// + /// Test-only authentication handler that signs every request in as an Admin. + /// Lets the endpoint's RequireAdmin policy pass without spinning up + /// the real cookie + LDAP pipeline. + /// + private sealed class FakeAuthHandler : AuthenticationHandler + { + public const string SchemeName = "FakeAuth"; + + public FakeAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) { } + + protected override Task HandleAuthenticateAsync() + { + var claims = new[] + { + new Claim(ClaimTypes.Name, "test-admin"), + new Claim(JwtTokenService.RoleClaimType, "Admin"), + }; + var identity = new ClaimsIdentity(claims, SchemeName); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, SchemeName); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } + + [Fact] + public void ExportEndpoint_RouteIsRegistered() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddRouting(); + builder.Services.AddAuthorization(); + builder.Services.AddSingleton(Substitute.For()); + builder.Services.AddScoped(); + // Dispose the host: an undisposed WebApplication leaks its config + // PhysicalFileProvider watcher and the ConsoleLoggerProcessor thread. + using var app = builder.Build(); + app.MapAuditExportEndpoints(); + + var endpoints = ((IEndpointRouteBuilder)app).DataSources + .SelectMany(ds => ds.Endpoints) + .OfType() + .ToList(); + + var export = endpoints.FirstOrDefault(e => + e.RoutePattern.RawText == "/api/centralui/audit/export" && + (e.Metadata.GetMetadata()?.HttpMethods.Contains("GET") ?? false)); + + Assert.NotNull(export); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs new file mode 100644 index 0000000..136a929 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.WebUtilities; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; +using AuditLogPage = ScadaLink.CentralUI.Components.Pages.Audit.AuditLogPage; + +namespace ScadaLink.CentralUI.Tests.Pages; + +/// +/// Unit tests for (#23 M7-T14 / +/// Bundle F). Builds the ?... querystring the Export-CSV link points +/// at; the same conversion is round-tripped on the server side by +/// . +/// These tests pin the no-filter base path + the round-trip back through +/// so the link contract stays stable. +/// +public class AuditLogPageExportUrlTests +{ + [Fact] + public void BuildExportUrl_NullFilter_ReturnsBasePath() + { + var url = AuditLogPage.BuildExportUrl(null); + Assert.Equal("/api/centralui/audit/export", url); + } + + [Fact] + public void BuildExportUrl_EmptyFilter_ReturnsBasePath() + { + // Defensive: a filter where every column is null should still render + // as the bare path — no trailing "?" so the URL stays clean. + var url = AuditLogPage.BuildExportUrl(new AuditLogQueryFilter()); + Assert.Equal("/api/centralui/audit/export", url); + } + + [Fact] + public void BuildExportUrl_AllFiltersSet_RoundTrips() + { + var corr = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + var filter = new AuditLogQueryFilter( + Channel: AuditChannel.ApiOutbound, + Kind: AuditKind.ApiCall, + Status: AuditStatus.Failed, + SourceSiteId: "plant-a", + Target: "PaymentApi", + Actor: "apikey-1", + CorrelationId: corr, + FromUtc: new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc), + ToUtc: new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)); + + var url = AuditLogPage.BuildExportUrl(filter); + + Assert.StartsWith("/api/centralui/audit/export?", url); + var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query); + + Assert.Equal("ApiOutbound", query["channel"]); + Assert.Equal("ApiCall", query["kind"]); + Assert.Equal("Failed", query["status"]); + Assert.Equal("plant-a", query["site"]); + Assert.Equal("PaymentApi", query["target"]); + Assert.Equal("apikey-1", query["actor"]); + Assert.Equal(corr.ToString(), query["correlationId"]); + Assert.Equal("2026-05-20T00:00:00.0000000Z", query["from"]); + Assert.Equal("2026-05-20T23:59:59.0000000Z", query["to"]); + } + + [Fact] + public void BuildExportUrl_OnlyChannelSet_OmitsOtherParams() + { + var filter = new AuditLogQueryFilter(Channel: AuditChannel.Notification); + + var url = AuditLogPage.BuildExportUrl(filter); + + Assert.StartsWith("/api/centralui/audit/export?", url); + var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query); + Assert.Single(query); + Assert.Equal("Notification", query["channel"]); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj b/tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj index cbab8f3..4b1faa7 100644 --- a/tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj +++ b/tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogExportServiceTests.cs b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogExportServiceTests.cs new file mode 100644 index 0000000..3d58b5e --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogExportServiceTests.cs @@ -0,0 +1,310 @@ +using System.Text; +using NSubstitute; +using ScadaLink.CentralUI.Services; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Tests.Services; + +/// +/// Tests for (#23 M7-T14 / Bundle F). The +/// service streams the filtered Audit Log query to a destination stream as +/// RFC 4180 CSV. These tests pin: +/// +/// Header + body row count for a simple page. +/// RFC 4180 quoting for fields containing commas / quotes / CR-LF. +/// Null fields render as empty (no literal "null"). +/// Row cap honoured + cap footer appended. +/// Cancellation tokens propagate mid-stream. +/// +/// +public class AuditLogExportServiceTests +{ + private static AuditEvent SimpleEvent(string id, string? target = null, string? error = null) + => new() + { + EventId = Guid.Parse(id), + OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + IngestedAtUtc = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc), + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + CorrelationId = null, + SourceSiteId = "plant-a", + SourceInstanceId = null, + SourceScript = null, + Actor = null, + Target = target, + Status = AuditStatus.Delivered, + HttpStatus = 200, + DurationMs = 42, + ErrorMessage = error, + ErrorDetail = null, + RequestSummary = null, + ResponseSummary = null, + PayloadTruncated = false, + Extra = null, + ForwardState = null, + }; + + [Fact] + public async Task ExportAsync_FiveRows_WritesHeaderPlusFiveRows() + { + var rows = new List + { + SimpleEvent("11111111-1111-1111-1111-111111111111"), + SimpleEvent("22222222-2222-2222-2222-222222222222"), + SimpleEvent("33333333-3333-3333-3333-333333333333"), + SimpleEvent("44444444-4444-4444-4444-444444444444"), + SimpleEvent("55555555-5555-5555-5555-555555555555"), + }; + var repo = Substitute.For(); + // First call returns the 5 rows; subsequent calls return empty so the + // service terminates the keyset loop. + repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns( + Task.FromResult>(rows), + Task.FromResult>(Array.Empty())); + + var sut = new AuditLogExportService(repo); + + using var ms = new MemoryStream(); + await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 100, ms, CancellationToken.None); + + var csv = Encoding.UTF8.GetString(ms.ToArray()); + var lines = csv.Split("\r\n", StringSplitOptions.None); + + // 1 header + 5 rows + trailing empty token from final \r\n = 7 entries. + Assert.Equal(7, lines.Length); + Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId,SourceSiteId,", lines[0]); + Assert.StartsWith("11111111-1111-1111-1111-111111111111,", lines[1]); + Assert.StartsWith("55555555-5555-5555-5555-555555555555,", lines[5]); + Assert.Equal(string.Empty, lines[6]); + } + + [Fact] + public async Task ExportAsync_HeaderHasAll21Columns_InSpecOrder() + { + var repo = Substitute.For(); + repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + + var sut = new AuditLogExportService(repo); + using var ms = new MemoryStream(); + await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None); + + var csv = Encoding.UTF8.GetString(ms.ToArray()).TrimEnd('\r', '\n'); + var header = csv.Split("\r\n")[0]; + var columns = header.Split(','); + + Assert.Equal(21, columns.Length); + Assert.Equal(new[] + { + "EventId", "OccurredAtUtc", "IngestedAtUtc", "Channel", "Kind", + "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", + "Actor", "Target", "Status", "HttpStatus", "DurationMs", + "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", + "PayloadTruncated", "Extra", "ForwardState", + }, columns); + } + + [Fact] + public async Task ExportAsync_FieldWithComma_QuotedAndEscaped() + { + // Target contains a comma → field must be wrapped in double quotes. + // Target with embedded quote → quote must be doubled ("") and field quoted. + // ResponseSummary contains CR-LF → field must be quoted. + var row = new AuditEvent + { + EventId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + IngestedAtUtc = null, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + CorrelationId = null, + SourceSiteId = "plant-a, secondary", // comma + SourceInstanceId = null, + SourceScript = "say \"hi\"", // embedded quote + Actor = null, + Target = "x", + Status = AuditStatus.Delivered, + HttpStatus = null, + DurationMs = null, + ErrorMessage = "boom\r\nthen again", // CR-LF + ErrorDetail = null, + RequestSummary = null, + ResponseSummary = null, + PayloadTruncated = false, + Extra = null, + ForwardState = null, + }; + var repo = Substitute.For(); + repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns( + Task.FromResult>(new[] { row }), + Task.FromResult>(Array.Empty())); + + var sut = new AuditLogExportService(repo); + using var ms = new MemoryStream(); + await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None); + + var csv = Encoding.UTF8.GetString(ms.ToArray()); + + // Comma-bearing field is quoted. + Assert.Contains("\"plant-a, secondary\"", csv); + // Embedded quote is doubled inside a quoted field. + Assert.Contains("\"say \"\"hi\"\"\"", csv); + // Newline-bearing field is quoted; the inner \r\n stays as-is. + Assert.Contains("\"boom\r\nthen again\"", csv); + } + + [Fact] + public async Task ExportAsync_NullField_WrittenAsEmpty() + { + // Build a row with deliberate nulls for every nullable column. + var row = new AuditEvent + { + EventId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + IngestedAtUtc = null, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + CorrelationId = null, + SourceSiteId = null, + SourceInstanceId = null, + SourceScript = null, + Actor = null, + Target = null, + Status = AuditStatus.Submitted, + HttpStatus = null, + DurationMs = null, + ErrorMessage = null, + ErrorDetail = null, + RequestSummary = null, + ResponseSummary = null, + PayloadTruncated = false, + Extra = null, + ForwardState = null, + }; + var repo = Substitute.For(); + repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns( + Task.FromResult>(new[] { row }), + Task.FromResult>(Array.Empty())); + + var sut = new AuditLogExportService(repo); + using var ms = new MemoryStream(); + await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None); + + var csv = Encoding.UTF8.GetString(ms.ToArray()); + var dataLine = csv.Split("\r\n")[1]; + var fields = dataLine.Split(','); + + // EventId(0), OccurredAtUtc(1), IngestedAtUtc(2), Channel(3), Kind(4), + // CorrelationId(5), SourceSiteId(6), SourceInstanceId(7), SourceScript(8), + // Actor(9), Target(10), Status(11), HttpStatus(12), DurationMs(13), + // ErrorMessage(14), ErrorDetail(15), RequestSummary(16), ResponseSummary(17), + // PayloadTruncated(18), Extra(19), ForwardState(20) + Assert.Equal(21, fields.Length); + Assert.Equal("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", fields[0]); + Assert.Equal(string.Empty, fields[2]); // IngestedAtUtc null + Assert.Equal(string.Empty, fields[5]); // CorrelationId null + Assert.Equal(string.Empty, fields[6]); // SourceSiteId null + Assert.Equal(string.Empty, fields[12]); // HttpStatus null + Assert.Equal(string.Empty, fields[14]); // ErrorMessage null + Assert.Equal("False", fields[18]); // PayloadTruncated + Assert.Equal(string.Empty, fields[20]); // ForwardState null + } + + [Fact] + public async Task ExportAsync_RowCountAboveCap_Truncates_AppendsCapFooter() + { + // The service is asked for 3 rows but the repo would happily yield 5. + // Output must contain exactly 3 data rows + a footer "# Capped at 3 rows..." + var rows = Enumerable.Range(0, 5) + .Select(i => SimpleEvent(Guid.NewGuid().ToString())) + .ToList(); + var repo = Substitute.For(); + // Repo returns the 5 rows in a single page; the service must stop after 3. + repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(rows)); + + var sut = new AuditLogExportService(repo); + using var ms = new MemoryStream(); + await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 3, ms, CancellationToken.None); + + var csv = Encoding.UTF8.GetString(ms.ToArray()); + var lines = csv.Split("\r\n", StringSplitOptions.None); + + // 1 header + 3 rows + 1 footer + trailing empty = 6 entries. + Assert.Equal(6, lines.Length); + Assert.Equal("# Capped at 3 rows. Use the CLI for larger exports.", lines[4]); + } + + [Fact] + public async Task ExportAsync_CancellationToken_StopsMidStream() + { + // Repo yields a single page, then on the next page call we observe the + // canceled token and throw — service should propagate OperationCanceled. + var cts = new CancellationTokenSource(); + var firstPage = new List + { + SimpleEvent("11111111-1111-1111-1111-111111111111"), + SimpleEvent("22222222-2222-2222-2222-222222222222"), + }; + + var repo = Substitute.For(); + repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + // Cancel after delivering the first page so the next loop iteration + // sees a canceled token. + cts.Cancel(); + return Task.FromResult>(firstPage); + }); + + var sut = new AuditLogExportService(repo); + using var ms = new MemoryStream(); + + // The service writes the first page then checks the token before pulling + // the next — we expect OperationCanceledException to surface. + await Assert.ThrowsAnyAsync(async () => + await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 1000, ms, cts.Token)); + } + + [Fact] + public async Task ExportAsync_PaginatesUsingLastRowAsCursor() + { + // Two pages of 2 rows each, then empty. The service must pass the last + // row of page 1 as the cursor on the page-2 call. + var p1 = new List + { + new() { EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"), OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered }, + new() { EventId = Guid.Parse("22222222-2222-2222-2222-222222222222"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 59, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered }, + }; + var p2 = new List + { + new() { EventId = Guid.Parse("33333333-3333-3333-3333-333333333333"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 58, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered }, + }; + + var pagings = new List(); + var repo = Substitute.For(); + repo.QueryAsync(Arg.Any(), Arg.Do(p => pagings.Add(p)), Arg.Any()) + .Returns( + Task.FromResult>(p1), + Task.FromResult>(p2), + Task.FromResult>(Array.Empty())); + + var sut = new AuditLogExportService(repo); + using var ms = new MemoryStream(); + // PageSize is 2 so the first page returns full and the loop continues. + await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None, pageSize: 2); + + Assert.True(pagings.Count >= 2, $"Expected at least 2 paged calls, got {pagings.Count}"); + Assert.Null(pagings[0].AfterEventId); + Assert.Null(pagings[0].AfterOccurredAtUtc); + Assert.Equal(p1[^1].EventId, pagings[1].AfterEventId); + Assert.Equal(p1[^1].OccurredAtUtc, pagings[1].AfterOccurredAtUtc); + } +} From 6dea84cd283b0eced3ff617e41daf06a9fbe5018 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 21:09:42 -0400 Subject: [PATCH 12/14] feat(security): OperationalAudit + AuditExport permissions for Audit Log surface (#23 M7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle G (#23 M7-T15): replace the temporary Admin-only gate on the Audit Log surface with two new permission policies — OperationalAudit (read) and AuditExport (bulk-export) — so the read path and the forensic-export path can be delegated independently. ScadaLink.Security - AuthorizationPolicies: add OperationalAudit + AuditExport policy constants; register them via RequireClaim with an explicit role allow-list (OperationalAuditRoles, AuditExportRoles) so the role-to-permission mapping is documented in one place. - Default mapping: Admin and Audit roles grant both policies; AuditReadOnly grants OperationalAudit only (read access without bulk export); Design and Deployment grant neither. ScadaLink.CentralUI - AuditLogPage: switch the page-level [Authorize] to the OperationalAudit policy and wrap the Export-CSV button in an AuthorizeView gated on AuditExport so an OperationalAudit-only operator still sees the page + filters but cannot trigger the CSV pull. - ConfigurationAuditLog: switch from RequireAdmin to OperationalAudit so both pages under the Audit nav group share the same gate. - NavMenu: the Audit nav group now gates on OperationalAudit so the section header + both child links match the per-page policies. - AuditExportEndpoints: switch RequireAuthorization from RequireAdmin to AuditExport — this is the authoritative gate; the AuthorizeView on the button is just a UX affordance. Tests - New AuditLogPagePermissionTests covers the 5 brief-mandated cases plus defence-in-depth for Admin-alone and AuditReadOnly users on the endpoint. - SecurityTests: add policy-level coverage for the new role→permission matrix (Theory rows pin every role/policy combination). - AuditExportEndpointsTests: switch to AddScadaLinkAuthorization() so the test host exercises the real production wiring under the new gate. - AuditLogPageScaffoldTests: wrap the page render in a CascadingAuthenticationState so the new in-page AuthorizeView resolves the principal. --- .../Audit/AuditExportEndpoints.cs | 11 +- .../Components/Layout/NavMenu.razor | 14 +- .../Components/Pages/Audit/AuditLogPage.razor | 33 +- .../Pages/Audit/ConfigurationAuditLog.razor | 2 +- .../AuthorizationPolicies.cs | 99 ++++++ .../Audit/AuditExportEndpointsTests.cs | 22 +- .../Pages/AuditLogPagePermissionTests.cs | 323 ++++++++++++++++++ .../Pages/AuditLogPageScaffoldTests.cs | 14 +- .../ScadaLink.Security.Tests/SecurityTests.cs | 53 +++ 9 files changed, 538 insertions(+), 33 deletions(-) create mode 100644 tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs diff --git a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs index 66ed746..fdf34f6 100644 --- a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs +++ b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs @@ -23,9 +23,12 @@ namespace ScadaLink.CentralUI.Audit; /// /// /// -/// The route is admin-gated to mirror the NavMenu (RequireAdmin wraps -/// the Audit section). The query-string parser silently drops unrecognised -/// values to match the page-level parser in +/// The route is gated on the +/// policy (#23 M7-T15 / Bundle G) so only roles with the bulk-export +/// permission can pull a CSV — the page-level +/// gate is read-only +/// and intentionally narrower. The query-string parser silently drops +/// unrecognised values to match the page-level parser in /// AuditLogPage.ApplyQueryStringFilters — an unknown enum value yields /// the same "no constraint" outcome rather than a 400. /// @@ -43,7 +46,7 @@ public static class AuditExportEndpoints public static IEndpointRouteBuilder MapAuditExportEndpoints(this IEndpointRouteBuilder endpoints) { endpoints.MapGet("/api/centralui/audit/export", HandleExportAsync) - .RequireAuthorization(AuthorizationPolicies.RequireAdmin); + .RequireAuthorization(AuthorizationPolicies.AuditExport); return endpoints; } diff --git a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor index 798d306..1c05b7e 100644 --- a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor @@ -108,11 +108,15 @@ - @* Audit — Admin only. Hosts the new Audit Log page (#23 M7) - and the renamed Configuration Audit Log (IAuditService - config-change viewer). Both items are Admin-gated, so - the section header sits inside the same policy block. *@ - + @* Audit — gated on the OperationalAudit policy (#23 M7-T15 + / Bundle G). Hosts the new Audit Log page (#23 M7) and + the renamed Configuration Audit Log (IAuditService + config-change viewer). Both items share the same gate, + so the section header sits inside the same policy block: + a non-audit user does not even see the heading. + OperationalAudit is satisfied by the Admin, Audit, and + AuditReadOnly roles. *@ +