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)
diff --git a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs
new file mode 100644
index 0000000..fdf34f6
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs
@@ -0,0 +1,173 @@
+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 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.
+///
+///
+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.AuditExport);
+
+ 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/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/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..ba052d3
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs
@@ -0,0 +1,144 @@
+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; }
+
+ ///
+ /// 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.
+ 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/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())
+ {
+ @col.Label
+ }
+
+
+
+ @if (_rows.Count == 0)
+ {
+
+
+ @if (_loading)
+ {
+ Loading…
+ }
+ else
+ {
+ No audit events match the current filter.
+ }
+
+
+ }
+ else
+ {
+ @foreach (var row in _rows)
+ {
+ HandleRowClick(row)">
+ @foreach (var col in OrderedColumns())
+ {
+
+ @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/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/Layout/NavMenu.razor b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor
index 78fb4f2..1c05b7e 100644
--- a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor
+++ b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor
@@ -108,11 +108,22 @@
- @* Audit Log — Admin only *@
-
+ @* 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. *@
+
+
- Audit Log
+ Audit Log
+
+
+ Configuration Audit Log
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor
index b47872d..e6bd069 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor
+++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor
@@ -27,6 +27,17 @@
@:Add API Key
}
+ @* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log
+ pre-filtered to this API key's inbound calls. Inbound audit rows record
+ the key Name as Actor and live on the ApiInbound channel. *@
+ @if (IsEditMode && !string.IsNullOrWhiteSpace(_formName))
+ {
+
+ Recent audit activity
+
+ }
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
+
+ }
+
Audit Log
+
+
+ Audit Log
+
+ @* 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. *@
+
+
+
+
+ @* 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.
+
+ Bundle G (#23 M7-T15) gates the button on the AuditExport policy so an
+ OperationalAudit-only operator (read access without bulk export) sees the
+ page + filters but cannot trigger the CSV pull. The endpoint itself is
+ gated separately, so a hand-crafted URL still 403s — the AuthorizeView
+ here is the user-facing affordance, not the authoritative check. *@
+
+
+
+
+ Export CSV
+
+
+
+
+
+ @* 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. *@
+
+
+
+
+
+@* 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
new file mode 100644
index 0000000..b3c05ff
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs
@@ -0,0 +1,228 @@
+using System.Globalization;
+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;
+
+///
+/// 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.
+///
+///
+/// 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. 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.
+///
+///
+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;
+ }
+
+ // 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))
+ {
+ 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 && status is null)
+ {
+ return;
+ }
+
+ _currentFilter = new AuditLogQueryFilter(
+ Channel: channel,
+ Status: status,
+ SourceSiteId: site,
+ Target: target,
+ Actor: actor,
+ CorrelationId: correlationId);
+ }
+
+ 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)
+ {
+ // 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;
+ }
+
+ ///
+ /// 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/Components/Pages/Monitoring/AuditLog.razor b/src/ScadaLink.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor
similarity index 98%
rename from src/ScadaLink.CentralUI/Components/Pages/Monitoring/AuditLog.razor
rename to src/ScadaLink.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor
index 96d7108..e7d76f4 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/AuditLog.razor
+++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor
@@ -1,14 +1,14 @@
-@page "/monitoring/audit-log"
+@page "/audit/configuration"
@using ScadaLink.Security
@using ScadaLink.CentralUI.Components
@using ScadaLink.Commons.Entities.Audit
@using ScadaLink.Commons.Interfaces.Repositories
-@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
+@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
@inject ICentralUiRepository CentralUiRepository
@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/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
index e3af35f..12aab37 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
+++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
@@ -22,6 +22,18 @@
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/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/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/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 0caf98c..acec819 100644
--- a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs
+++ b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs
@@ -3,6 +3,8 @@ using Microsoft.Extensions.DependencyInjection;
using ScadaLink.CentralUI.Auth;
using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.CentralUI.ScriptAnalysis;
+using ScadaLink.CentralUI.Services;
+using ScadaLink.HealthMonitoring;
namespace ScadaLink.CentralUI;
@@ -27,6 +29,23 @@ 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.
+ //
+ // Registered with an explicit factory so the IServiceScopeFactory ctor is
+ // always chosen — AuditLogQueryService has a second (test-seam) ctor that
+ // takes IAuditLogRepository directly, and both are constructor-resolvable,
+ // so default activation would be ambiguous. The scope-factory ctor opens a
+ // fresh DbContext per query, which is what keeps the page's auto-load from
+ // racing AuditFilterBar's site enumeration on the shared scoped context.
+ services.AddScoped(sp => new AuditLogQueryService(
+ sp.GetRequiredService(),
+ sp.GetRequiredService()));
+
+ // 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/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs
new file mode 100644
index 0000000..f4bd2f4
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs
@@ -0,0 +1,135 @@
+using Microsoft.Extensions.DependencyInjection;
+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;
+
+///
+/// Default implementation — a thin pass-through
+/// to . Default page size is 100 (the
+/// AuditResultsGrid default for #23 M7).
+///
+///
+///
+/// #23 M7 (Bundle H follow-up): each query opens its OWN DI scope and resolves a
+/// fresh — and therefore a fresh
+/// ScadaLinkDbContext — rather than sharing the scoped Blazor-circuit
+/// context. Without this, the Audit Log page's query-string auto-load
+/// (/audit/log?correlationId=… ) races AuditFilterBar.GetAllSitesAsync()
+/// on the single circuit-scoped ScadaLinkDbContext , producing EF Core's
+/// "A second operation was started on this context instance" error. Scope-per-query
+/// removes the shared state so the two initial loads can run concurrently. This
+/// mirrors the scope-per-message pattern used by AuditLogIngestActor .
+///
+///
+/// A second constructor takes an directly — a
+/// test seam (mirroring AuditLogIngestActor 's dual ctor) so unit tests can
+/// substitute a stub without standing up a DI container.
+///
+///
+public sealed class AuditLogQueryService : IAuditLogQueryService
+{
+ // 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);
+
+ // Production path: open a fresh scope per operation. Null in the test-seam ctor.
+ private readonly IServiceScopeFactory? _scopeFactory;
+
+ // Test seam: a directly-injected repository whose lifetime the test owns.
+ // Null in the production ctor.
+ private readonly IAuditLogRepository? _injectedRepository;
+
+ private readonly ICentralHealthAggregator _healthAggregator;
+
+ ///
+ /// Production constructor — resolves from a
+ /// fresh DI scope on every call so each query gets its own
+ /// ScadaLinkDbContext and never contends with the circuit-scoped
+ /// context the filter bar uses.
+ ///
+ public AuditLogQueryService(
+ IServiceScopeFactory scopeFactory,
+ ICentralHealthAggregator healthAggregator)
+ {
+ _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
+ _healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
+ }
+
+ ///
+ /// Test-seam constructor — injects a repository instance whose lifetime the
+ /// caller owns. Used by unit tests that substitute a stub repository.
+ ///
+ public AuditLogQueryService(
+ IAuditLogRepository repository,
+ ICentralHealthAggregator healthAggregator)
+ {
+ _injectedRepository = repository ?? throw new ArgumentNullException(nameof(repository));
+ _healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
+ }
+
+ public int DefaultPageSize => 100;
+
+ public async Task> QueryAsync(
+ AuditLogQueryFilter filter,
+ AuditLogPaging? paging = null,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(filter);
+ var effective = paging ?? new AuditLogPaging(DefaultPageSize);
+
+ // Test-seam ctor: use the injected repository directly.
+ if (_injectedRepository is not null)
+ {
+ return await _injectedRepository.QueryAsync(filter, effective, ct);
+ }
+
+ // Production: a fresh scope (and thus a fresh DbContext) per query so the
+ // page's auto-load never shares the circuit-scoped context.
+ await using var scope = _scopeFactory!.CreateAsyncScope();
+ var repository = scope.ServiceProvider.GetRequiredService();
+ return await 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. Resolved via a per-call scope on
+ // the production path for the same context-isolation reason as
+ // QueryAsync; the test-seam ctor uses the injected repository.
+ AuditLogKpiSnapshot repoSnapshot;
+ if (_injectedRepository is not null)
+ {
+ repoSnapshot = await _injectedRepository.GetKpiSnapshotAsync(KpiWindow, nowUtc: null, ct);
+ }
+ else
+ {
+ await using var scope = _scopeFactory!.CreateAsyncScope();
+ var repository = scope.ServiceProvider.GetRequiredService();
+ 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
new file mode 100644
index 0000000..08b85d8
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs
@@ -0,0 +1,53 @@
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Types;
+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; }
+
+ ///
+ /// 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/src/ScadaLink.Security/AuthorizationPolicies.cs b/src/ScadaLink.Security/AuthorizationPolicies.cs
index c426b74..200c778 100644
--- a/src/ScadaLink.Security/AuthorizationPolicies.cs
+++ b/src/ScadaLink.Security/AuthorizationPolicies.cs
@@ -3,12 +3,98 @@ using Microsoft.Extensions.DependencyInjection;
namespace ScadaLink.Security;
+///
+/// Centralised authorization policy names + the role→permission mapping
+/// that defines them.
+///
+///
+/// The codebase uses a thin role-claim model: each policy expresses a
+/// permission, satisfied when the principal carries any role claim
+/// ( ) that maps to that
+/// permission. Role names are free strings configured via
+/// rows
+/// (see ) — there is no permission claim, just a
+/// fan-out from role to allowed policies.
+///
+///
+///
+/// Default role → permission mapping (#23 M7-T15 / Bundle G):
+///
+///
+/// Role
+/// Policies granted
+///
+/// -
+///
Admin
+/// ,
+/// , — admins hold
+/// every permission by convention so an Admin-only user never loses
+/// access to a new surface.
+///
+/// -
+///
Design
+///
+///
+/// -
+///
Deployment
+///
+///
+/// -
+///
Audit
+/// ,
+/// — the full audit surface (read + bulk
+/// export) per Component-AuditLog.md §"Authorization".
+///
+/// -
+///
AuditReadOnly
+/// only — operators who
+/// should see the Audit Log + drill in to incidents but not pull bulk
+/// CSV exports. Use this when delegating triage without granting
+/// forensic-export capability.
+///
+///
+/// LDAP group → role mapping is configured via the central UI Admin → LDAP
+/// Mappings page (rows in LdapGroupMappings ); the same code path
+/// reads them whether the role is one of the four built-ins above or any
+/// future addition. Adding a role here means adding the LDAP mapping row in
+/// the deployment; no schema migration is needed.
+///
+///
public static class AuthorizationPolicies
{
public const string RequireAdmin = "RequireAdmin";
public const string RequireDesign = "RequireDesign";
public const string RequireDeployment = "RequireDeployment";
+ ///
+ /// Read access to the Audit Log #23 surface (Audit Log page,
+ /// Configuration Audit Log page, Audit nav group). Granted to the
+ /// Audit role, the AuditReadOnly role, and the
+ /// Admin role.
+ ///
+ public const string OperationalAudit = "OperationalAudit";
+
+ ///
+ /// Permission to pull a bulk CSV export of the Audit Log. Separate from
+ /// so a triage operator can read the
+ /// table without being able to exfiltrate it in bulk. Granted to the
+ /// Audit role and the Admin role.
+ ///
+ public const string AuditExport = "AuditExport";
+
+ ///
+ /// Roles that satisfy . Held in one place
+ /// so the seed/docs and the policy stay in lockstep.
+ ///
+ internal static readonly string[] OperationalAuditRoles = { "Admin", "Audit", "AuditReadOnly" };
+
+ ///
+ /// Roles that satisfy . A strict subset of
+ /// — read access does NOT imply
+ /// export permission.
+ ///
+ internal static readonly string[] AuditExportRoles = { "Admin", "Audit" };
+
public static IServiceCollection AddScadaLinkAuthorization(this IServiceCollection services)
{
services.AddAuthorization(options =>
@@ -21,6 +107,19 @@ public static class AuthorizationPolicies
options.AddPolicy(RequireDeployment, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, "Deployment"));
+
+ // Multi-role permission policies — the policy succeeds when the
+ // principal holds ANY of the mapped roles. RequireClaim with
+ // multiple allowed values is the right primitive: it checks
+ // whether *any* role claim's value is in the allowed set, so a
+ // user with role=Admin (and nothing else) satisfies the
+ // OperationalAudit policy without needing a separate Audit
+ // role claim.
+ options.AddPolicy(OperationalAudit, policy =>
+ policy.RequireClaim(JwtTokenService.RoleClaimType, OperationalAuditRoles));
+
+ options.AddPolicy(AuditExport, policy =>
+ policy.RequireClaim(JwtTokenService.RoleClaimType, AuditExportRoles));
});
services.AddSingleton();
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.PlaywrightTests/Audit/AuditDataSeeder.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs
new file mode 100644
index 0000000..31252af
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs
@@ -0,0 +1,153 @@
+using Microsoft.Data.SqlClient;
+
+namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
+
+///
+/// Direct-SQL seeding helper for the Audit Log Playwright E2E tests (#23 M7-T16).
+///
+///
+/// The Playwright suite runs against the live Docker cluster (the same one that
+/// answers http://localhost:9000 ), which talks to the ScadaLinkConfig
+/// database on localhost:1433 . infra/mssql/seed-config.sql is off
+/// limits per the task's strict rules, so each test inserts its own
+/// AuditLog rows at setup time and best-effort deletes them at teardown.
+///
+///
+///
+/// Rows are tagged with a unique Target prefix derived from the test
+/// name + a GUID so the teardown DELETE never touches rows the cluster
+/// itself produced. The OccurredAtUtc is pinned to "now" so the default
+///
+/// time-range filter still sees the row after Apply.
+///
+///
+///
+/// Connection string mirrors the Docker cluster's scadalink_app account
+/// from docker/central-node-a/appsettings.Central.json , with the host
+/// pointed at the host-exposed port (localhost:1433 ). The
+/// SCADALINK_PLAYWRIGHT_DB env var lets CI override the connection
+/// without recompiling.
+///
+///
+internal static class AuditDataSeeder
+{
+ private const string DefaultConnectionString =
+ "Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=5";
+
+ private const string EnvVar = "SCADALINK_PLAYWRIGHT_DB";
+
+ ///
+ /// Connection string for the running cluster's configuration DB. Resolved
+ /// from SCADALINK_PLAYWRIGHT_DB when set, otherwise the local docker
+ /// dev defaults.
+ ///
+ public static string ConnectionString
+ {
+ get
+ {
+ var fromEnv = Environment.GetEnvironmentVariable(EnvVar);
+ return string.IsNullOrWhiteSpace(fromEnv) ? DefaultConnectionString : fromEnv;
+ }
+ }
+
+ ///
+ /// Inserts a single audit row into AuditLog . All optional fields are
+ /// nullable so individual tests can shape the row to whatever payload they
+ /// need for their drawer/grid assertions.
+ ///
+ public static async Task InsertAuditEventAsync(
+ Guid eventId,
+ DateTime occurredAtUtc,
+ string channel,
+ string kind,
+ string status,
+ string? sourceSiteId = null,
+ string? target = null,
+ string? actor = null,
+ Guid? correlationId = null,
+ int? httpStatus = null,
+ int? durationMs = null,
+ string? errorMessage = null,
+ string? requestSummary = null,
+ string? responseSummary = null,
+ string? extra = null,
+ CancellationToken ct = default)
+ {
+ const string sql = @"
+INSERT INTO [AuditLog]
+([EventId], [OccurredAtUtc], [IngestedAtUtc], [Channel], [Kind], [CorrelationId],
+ [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target], [Status],
+ [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary],
+ [ResponseSummary], [PayloadTruncated], [Extra], [ForwardState])
+VALUES
+(@eventId, @occurredAtUtc, SYSUTCDATETIME(), @channel, @kind, @correlationId,
+ @sourceSiteId, NULL, NULL, @actor, @target, @status,
+ @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary,
+ @responseSummary, 0, @extra, NULL);";
+
+ await using var connection = new SqlConnection(ConnectionString);
+ await connection.OpenAsync(ct);
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = sql;
+ cmd.Parameters.AddWithValue("@eventId", eventId);
+ cmd.Parameters.AddWithValue("@occurredAtUtc", occurredAtUtc);
+ cmd.Parameters.AddWithValue("@channel", channel);
+ cmd.Parameters.AddWithValue("@kind", kind);
+ cmd.Parameters.AddWithValue("@correlationId", (object?)correlationId ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@sourceSiteId", (object?)sourceSiteId ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@target", (object?)target ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@status", status);
+ cmd.Parameters.AddWithValue("@httpStatus", (object?)httpStatus ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@durationMs", (object?)durationMs ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@errorMessage", (object?)errorMessage ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@requestSummary", (object?)requestSummary ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@responseSummary", (object?)responseSummary ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@extra", (object?)extra ?? DBNull.Value);
+
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ ///
+ /// Best-effort cleanup. Deletes every AuditLog row whose Target
+ /// starts with . Swallows all errors — a
+ /// stuck row carrying a random GUID suffix does not collide with future
+ /// runs and tests should not fail teardown.
+ ///
+ public static async Task DeleteByTargetPrefixAsync(string targetPrefix, CancellationToken ct = default)
+ {
+ try
+ {
+ await using var connection = new SqlConnection(ConnectionString);
+ await connection.OpenAsync(ct);
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM [AuditLog] WHERE [Target] LIKE @prefix";
+ cmd.Parameters.AddWithValue("@prefix", targetPrefix + "%");
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+ catch
+ {
+ // Best-effort — the prefix carries a GUID so the rows are unique to
+ // this test run and won't collide on the next pass.
+ }
+ }
+
+ ///
+ /// Probe whether the configuration DB is reachable. Tests gate their
+ /// per-test setup on this; when the cluster is down the test fails with a
+ /// clear "MSSQL unavailable" message instead of an opaque SqlException.
+ ///
+ public static async Task IsAvailableAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ await using var connection = new SqlConnection(ConnectionString);
+ await connection.OpenAsync(ct);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs
new file mode 100644
index 0000000..59cffcf
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs
@@ -0,0 +1,379 @@
+using Microsoft.Playwright;
+
+namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
+
+///
+/// End-to-end coverage for the central Audit Log surface (#23 M7-T16 / Bundle H).
+///
+///
+/// Each test seeds its own AuditLog rows directly into the running cluster's
+/// configuration database via , exercises the UI
+/// through Playwright against http://scadalink-traefik , then best-effort
+/// deletes the rows by their Target prefix. The seed/cleanup pattern keeps
+/// each test self-contained without touching infra/mssql/seed-config.sql .
+///
+///
+///
+/// Scenarios covered (per the M7-T16 brief):
+///
+/// FilterNarrowing — channel chip narrows the results grid.
+/// DrilldownDrawer_JsonPrettyPrint — JSON request bodies pretty-print.
+/// CopyAsCurlButton_VisibleOnApiInbound — cURL action visible for API rows.
+/// DrillInFromCorrelationId_AutoLoadsAuditLog — query-string drill-in
+/// auto-loads the grid (the exact path the Notifications "View audit history"
+/// link relies on; verified by reproducing the link target directly because
+/// seeding a notification visible to the report page requires the Akka query
+/// path, not just an INSERT).
+/// NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist —
+/// the report page wires drill-in links when notifications are present.
+/// ExportCsv_LinkIsVisibleAndDownloads — Export CSV button gated on
+/// the AuditExport policy, click initiates a download.
+/// PermissionGating_DesignerWithoutOperationalAudit_SeesNotAuthorized
+/// — the page-level [Authorize(Policy = OperationalAudit)] gate blocks a
+/// Design-only user.
+///
+///
+///
+[Collection("Playwright")]
+public class AuditLogPageTests
+{
+ private readonly PlaywrightFixture _fixture;
+
+ public AuditLogPageTests(PlaywrightFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ [Fact]
+ public async Task FilterNarrowing_ChannelChipShrinksGrid()
+ {
+ // Skip with a clear message when MSSQL is not reachable — the rest of
+ // the Playwright suite is UI-only and does not need the DB, so this
+ // surfaces a setup gap explicitly rather than as an opaque SqlException.
+ if (!await AuditDataSeeder.IsAvailableAsync())
+ {
+ throw new InvalidOperationException(
+ "AuditDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
+ "or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string.");
+ }
+
+ var runId = Guid.NewGuid().ToString("N");
+ var targetPrefix = $"playwright-test/filter-narrow/{runId}/";
+ var apiEventId = Guid.NewGuid();
+ var dbEventId = Guid.NewGuid();
+ var now = DateTime.UtcNow;
+
+ try
+ {
+ // One ApiOutbound row, one DbOutbound row — distinct Targets so the
+ // grid renders predictable rows we can assert on by data-test id.
+ await AuditDataSeeder.InsertAuditEventAsync(
+ eventId: apiEventId,
+ occurredAtUtc: now,
+ channel: "ApiOutbound",
+ kind: "ApiCall",
+ status: "Delivered",
+ target: targetPrefix + "api",
+ httpStatus: 200,
+ durationMs: 42);
+
+ await AuditDataSeeder.InsertAuditEventAsync(
+ eventId: dbEventId,
+ occurredAtUtc: now,
+ channel: "DbOutbound",
+ kind: "DbWrite",
+ status: "Delivered",
+ target: targetPrefix + "db",
+ durationMs: 17);
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // Pre-Apply, both rows are absent because the grid stays empty until
+ // the user filters. Click the ApiOutbound chip then Apply.
+ await page.Locator("[data-test='chip-channel-ApiOutbound']").ClickAsync();
+ await page.Locator("[data-test='filter-apply']").ClickAsync();
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // The seeded ApiOutbound row is visible; the DbOutbound row is not
+ // (it was filtered out by the channel chip).
+ var apiRow = page.Locator($"[data-test='grid-row-{apiEventId}']");
+ var dbRow = page.Locator($"[data-test='grid-row-{dbEventId}']");
+ await Assertions.Expect(apiRow).ToBeVisibleAsync();
+ Assert.Equal(0, await dbRow.CountAsync());
+ }
+ finally
+ {
+ await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
+ }
+ }
+
+ [Fact]
+ public async Task DrilldownDrawer_JsonPrettyPrintsRequestBody()
+ {
+ if (!await AuditDataSeeder.IsAvailableAsync())
+ {
+ throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
+ }
+
+ var runId = Guid.NewGuid().ToString("N");
+ var targetPrefix = $"playwright-test/drilldown-json/{runId}/";
+ var eventId = Guid.NewGuid();
+ var now = DateTime.UtcNow;
+ var requestJson = "{\"method\":\"POST\",\"headers\":{\"X-Api-Key\":\"abc123\"},\"body\":{\"unit\":\"pump-7\",\"value\":42.5}}";
+
+ try
+ {
+ await AuditDataSeeder.InsertAuditEventAsync(
+ eventId: eventId,
+ occurredAtUtc: now,
+ channel: "ApiOutbound",
+ kind: "ApiCall",
+ status: "Delivered",
+ target: targetPrefix + "endpoint",
+ httpStatus: 200,
+ durationMs: 31,
+ requestSummary: requestJson);
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // Apply with no chips picked — default time-range LastHour matches the
+ // freshly-seeded row.
+ await page.Locator("[data-test='filter-apply']").ClickAsync();
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ var row = page.Locator($"[data-test='grid-row-{eventId}']");
+ await Assertions.Expect(row).ToBeVisibleAsync();
+ await row.ClickAsync();
+
+ var drawer = page.Locator("[data-test='audit-drilldown-drawer']");
+ await Assertions.Expect(drawer).ToBeVisibleAsync();
+
+ // The Request body section is rendered as a with the indented
+ // JSON. Pretty-printed JSON inserts newlines + spaces — the verbatim
+ // single-line string we seeded has neither. Asserting on "headers"
+ // being followed by a colon+newline confirms System.Text.Json's
+ // WriteIndented produced the body, not the raw RequestSummary.
+ var requestBody = page.Locator("[data-test='request-body'] pre");
+ await Assertions.Expect(requestBody).ToBeVisibleAsync();
+ var bodyText = await requestBody.TextContentAsync();
+ Assert.NotNull(bodyText);
+ // Pretty-printed JSON contains a newline after the opening '{'.
+ Assert.Contains("\n", bodyText);
+ // The first-level keys are indented (System.Text.Json uses 2 spaces).
+ Assert.Contains(" \"method\"", bodyText);
+ // And the inner object's body is also pretty-printed (nested indent).
+ Assert.Contains(" \"unit\"", bodyText);
+ }
+ finally
+ {
+ await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
+ }
+ }
+
+ [Fact]
+ public async Task CopyAsCurlButton_IsVisibleAndClickableForApiInbound()
+ {
+ if (!await AuditDataSeeder.IsAvailableAsync())
+ {
+ throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
+ }
+
+ var runId = Guid.NewGuid().ToString("N");
+ var targetPrefix = $"playwright-test/curl-button/{runId}/";
+ var eventId = Guid.NewGuid();
+ var now = DateTime.UtcNow;
+
+ try
+ {
+ await AuditDataSeeder.InsertAuditEventAsync(
+ eventId: eventId,
+ occurredAtUtc: now,
+ channel: "ApiInbound",
+ kind: "InboundRequest",
+ status: "Delivered",
+ target: targetPrefix + "method",
+ actor: "playwright-test-key",
+ httpStatus: 200,
+ durationMs: 12,
+ requestSummary: "{\"method\":\"POST\",\"headers\":{\"X-API-Key\":\"redacted\"},\"body\":{\"ping\":true}}");
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ await page.Locator("[data-test='filter-apply']").ClickAsync();
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ var row = page.Locator($"[data-test='grid-row-{eventId}']");
+ await Assertions.Expect(row).ToBeVisibleAsync();
+ await row.ClickAsync();
+
+ // The Copy-as-cURL button is only rendered for API channels — the
+ // drawer's IsApiChannel guard. We assert visibility + clickability
+ // only; clipboard content varies between headless and headed runs.
+ var curlButton = page.Locator("[data-test='copy-as-curl']");
+ await Assertions.Expect(curlButton).ToBeVisibleAsync();
+ await Assertions.Expect(curlButton).ToBeEnabledAsync();
+ // Clicking should not throw or crash the page — clipboard interop
+ // failures are swallowed by CopyCurl(). The page stays alive.
+ await curlButton.ClickAsync();
+ await Assertions.Expect(page.Locator("[data-test='audit-drilldown-drawer']")).ToBeVisibleAsync();
+ }
+ finally
+ {
+ await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
+ }
+ }
+
+ [Fact]
+ public async Task DrillInFromCorrelationId_LandsOnAuditLogWithFilterContext()
+ {
+ if (!await AuditDataSeeder.IsAvailableAsync())
+ {
+ throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
+ }
+
+ var runId = Guid.NewGuid().ToString("N");
+ var targetPrefix = $"playwright-test/drill-in/{runId}/";
+ var correlationId = Guid.NewGuid();
+ var eventId = Guid.NewGuid();
+ var now = DateTime.UtcNow;
+
+ try
+ {
+ await AuditDataSeeder.InsertAuditEventAsync(
+ eventId: eventId,
+ occurredAtUtc: now,
+ channel: "Notification",
+ kind: "NotifySend",
+ status: "Delivered",
+ target: targetPrefix + "ops-list",
+ correlationId: correlationId,
+ durationMs: 8);
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+
+ // This is the exact URL the Notifications "View audit history" link
+ // produces: /audit/log?correlationId={NotificationId}. We assert the
+ // drill-in lands on the Audit Log page, the query-string survives,
+ // and the audit results grid surface is rendered (the filter bar +
+ // grid are present, so an operator can refine + Apply from here).
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?correlationId={correlationId}");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ Assert.Contains($"correlationId={correlationId}", page.Url);
+ await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync();
+ await Assertions.Expect(page.Locator("[data-test='audit-filter-bar']")).ToBeVisibleAsync();
+ await Assertions.Expect(page.Locator("[data-test='audit-results-grid']")).ToBeVisibleAsync();
+
+ // Bundle D auto-load: the query-string drill-in populates the grid
+ // WITHOUT an Apply click. The grid resolves the ?correlationId= filter
+ // on OnInitialized and the seeded row appears.
+ //
+ // This was previously blocked by an EF "A second operation was started
+ // on this context instance" error — the page-level auto-load raced
+ // AuditFilterBar.GetAllSitesAsync() on the shared scoped Blazor
+ // DbContext. AuditLogQueryService now opens its own DI scope per query
+ // (scope-per-query), so the auto-load no longer contends with the
+ // filter bar's site enumeration and the assertion is restored.
+ var seededRow = page.Locator($"[data-test='grid-row-{eventId}']");
+ await Assertions.Expect(seededRow).ToBeVisibleAsync();
+ }
+ finally
+ {
+ await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
+ }
+ }
+
+ [Fact]
+ public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
+ {
+ // Lighter-weight than the auto-load test above: we don't need MSSQL,
+ // because we only verify that the Notifications page is reachable and
+ // is gated by the right policy — the link target itself is exercised by
+ // DrillInFromCorrelationId_AutoLoadsAuditLog. If the notifications list
+ // is empty (no seed) we still validate the empty-state markup is in
+ // place; if rows are present we confirm at least one "View audit history"
+ // link is rendered.
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/notifications/report");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // Page reachable for the multi-role user; the Notification Report page
+ // is Deployment-gated, so this also covers that authorization path.
+ Assert.Contains("/notifications/report", page.Url);
+ await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();
+
+ // When there are notifications visible, the "View audit history" link
+ // is rendered. When there are none, we just confirm the empty-state.
+ var auditLinks = page.Locator("a:has-text('View audit history')");
+ var linkCount = await auditLinks.CountAsync();
+ if (linkCount > 0)
+ {
+ var firstLink = auditLinks.First;
+ var href = await firstLink.GetAttributeAsync("href");
+ Assert.NotNull(href);
+ Assert.StartsWith("/audit/log?correlationId=", href);
+ }
+ // No notifications visible? The page still rendered correctly, which is
+ // all we can assert without exercising the Akka query path to seed one.
+ }
+
+ [Fact]
+ public async Task ExportCsv_LinkVisibleForAuditExportUserAndTriggersDownload()
+ {
+ // Multi-role test user holds Admin, which grants AuditExport — the
+ // Export-CSV anchor is gated by AuthorizeView Policy=AuditExport.
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ var exportLink = page.Locator("a:has-text('Export CSV')");
+ await Assertions.Expect(exportLink).ToBeVisibleAsync();
+ var href = await exportLink.GetAttributeAsync("href");
+ Assert.NotNull(href);
+ Assert.StartsWith("/api/centralui/audit/export", href!);
+
+ // Click the link via Playwright's download API so the framework drains
+ // the response body instead of letting the browser leave a dangling
+ // navigation. The endpoint streams text/csv; we assert the suggested
+ // filename matches the audit-log-{timestamp}.csv pattern from
+ // AuditExportEndpoints.HandleExportAsync.
+ var download = await page.RunAndWaitForDownloadAsync(async () =>
+ {
+ await exportLink.ClickAsync();
+ });
+
+ Assert.NotNull(download);
+ Assert.StartsWith("audit-log-", download.SuggestedFilename);
+ Assert.EndsWith(".csv", download.SuggestedFilename);
+ }
+
+ [Fact]
+ public async Task PermissionGating_DesignerWithoutOperationalAudit_IsDeniedAccess()
+ {
+ // The designer LDAP user holds only the Design role, which does NOT
+ // grant OperationalAudit (Component-AuditLog.md §Authorization +
+ // AuthorizationPolicies.OperationalAuditRoles). The page-level
+ // [Authorize(Policy = OperationalAudit)] attribute is enforced by the
+ // ASP.NET Core authorization middleware, which redirects an
+ // authenticated-but-unauthorized user to the cookie scheme's
+ // AccessDenied path (/Account/AccessDenied?ReturnUrl=...) BEFORE the
+ // Blazor router runs — so the audit page never renders.
+ var page = await _fixture.NewAuthenticatedPageAsync("designer", "password");
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // Access was denied: we landed on the AccessDenied path, not the audit
+ // page. The ReturnUrl carries the original /audit/log target.
+ Assert.Contains("/Account/AccessDenied", page.Url);
+ Assert.Contains("ReturnUrl", page.Url);
+
+ // The audit results grid never rendered for the unauthorized user.
+ Assert.Equal(0, await page.Locator("[data-test='audit-results-grid']").CountAsync());
+ }
+}
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) ───────────────
diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/ScadaLink.CentralUI.PlaywrightTests.csproj b/tests/ScadaLink.CentralUI.PlaywrightTests/ScadaLink.CentralUI.PlaywrightTests.csproj
index 32913fa..dd87843 100644
--- a/tests/ScadaLink.CentralUI.PlaywrightTests/ScadaLink.CentralUI.PlaywrightTests.csproj
+++ b/tests/ScadaLink.CentralUI.PlaywrightTests/ScadaLink.CentralUI.PlaywrightTests.csproj
@@ -10,6 +10,7 @@
+
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/Audit/AuditExportEndpointsTests.cs b/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs
new file mode 100644
index 0000000..286198b
--- /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 AuditExport-gated (#23 M7-T15 Bundle G);
+ // the tests run as pre-authenticated principals built by
+ // FakeAuthHandler (everyone has the Admin role), which is
+ // one of AuditExportRoles, so the policy succeeds.
+ services.AddAuthentication(FakeAuthHandler.SchemeName)
+ .AddScheme(
+ FakeAuthHandler.SchemeName, _ => { });
+ // Use the real production policy wiring so the endpoint's
+ // updated AuditExport gate (#23 M7-T15 Bundle G) is what
+ // the tests exercise. The fake principal carries the
+ // "Admin" role, which AuditExportRoles permits.
+ services.AddScadaLinkAuthorization();
+ 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.
+ /// Admin is in AuditExportRoles , so the endpoint's AuditExport policy
+ /// passes 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/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);
+ }
+}
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);
+ }
+}
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/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/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\"]"));
+ });
+ }
+}
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/Pages/AuditLogPagePermissionTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs
new file mode 100644
index 0000000..e3a754d
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs
@@ -0,0 +1,323 @@
+using System.Net;
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using Bunit;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Authorization;
+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;
+using AuditLogPage = ScadaLink.CentralUI.Components.Pages.Audit.AuditLogPage;
+
+namespace ScadaLink.CentralUI.Tests.Pages;
+
+///
+/// Permission-gating tests for the Audit Log surface (#23 M7-T15 / Bundle G).
+///
+///
+/// Bundle G introduces two new policies:
+///
+/// OperationalAudit — read access to the Audit Log page +
+/// Configuration Audit Log page + nav group.
+/// AuditExport — additional gate on the Export-CSV button and
+/// the streaming export endpoint.
+///
+/// Both policies are satisfied by the Audit role and (defence in depth)
+/// the Admin role — admins see everything by convention in this
+/// codebase. The tests pin both the page-level + endpoint-level enforcement,
+/// and the Export-button visibility split.
+///
+///
+public class AuditLogPagePermissionTests : 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 void WireUpPageDependencies()
+ {
+ // The page hosts AuditFilterBar + AuditResultsGrid which depend on
+ // ISiteRepository and IAuditLogQueryService — provide stand-ins so
+ // a permitted render is exercised end-to-end.
+ Services.AddSingleton(Substitute.For());
+ Services.AddSingleton(Substitute.For());
+ }
+
+ private IRenderedComponent RenderAuditLogPage(params string[] roles)
+ {
+ var user = BuildPrincipal(roles);
+ Services.AddSingleton(new TestAuthStateProvider(user));
+ Services.AddAuthorizationCore();
+ AuthorizationPolicies.AddScadaLinkAuthorization(Services);
+ Services.AddSingleton();
+ WireUpPageDependencies();
+
+ // Page-level [Authorize(Policy=...)] is enforced by the router in a
+ // live app. bUnit renders the component directly, so we wrap the
+ // page in a CascadingAuthenticationState so the in-page
+ // AuthorizeView for the Export button can read the principal.
+ var host = Render(parameters => parameters
+ .Add(p => p.ChildContent, (RenderFragment)(builder =>
+ {
+ builder.OpenComponent(0);
+ builder.CloseComponent();
+ })));
+
+ return host.FindComponent();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Test 1: WithoutOperationalAudit_PageReturns403_OrHidden
+ // ─────────────────────────────────────────────────────────────────────
+ //
+ // Page-level enforcement is the [Authorize(Policy = "OperationalAudit")]
+ // attribute on the .razor page. We can't easily smoke-test routing here,
+ // so we verify the attribute is present + the policy denies a principal
+ // that holds none of the permitting roles.
+
+ [Fact]
+ public async Task WithoutOperationalAudit_PolicyDenies()
+ {
+ // A Design-only user (no Audit, no Admin) must NOT satisfy the
+ // OperationalAudit policy.
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddScadaLinkAuthorization();
+ using var provider = services.BuildServiceProvider();
+ var authService = provider.GetRequiredService();
+
+ var principal = BuildPrincipal("Design");
+ var result = await authService.AuthorizeAsync(
+ principal, null, AuthorizationPolicies.OperationalAudit);
+
+ Assert.False(result.Succeeded);
+ }
+
+ [Fact]
+ public void AuditLogPage_HasOperationalAuditAuthorizeAttribute()
+ {
+ // Sanity-pin the attribute so the page-level gate can't regress to
+ // [Authorize] (any-authenticated) by accident.
+ var attributes = typeof(AuditLogPage)
+ .GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
+ .Cast()
+ .ToList();
+
+ Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
+ }
+
+ [Fact]
+ public void ConfigurationAuditLogPage_HasOperationalAuditAuthorizeAttribute()
+ {
+ // ConfigurationAuditLog mirrors the gate — both Audit-group pages
+ // share the OperationalAudit permission so the nav-group policy
+ // remains coherent with the per-page gates.
+ var configType = typeof(ScadaLink.CentralUI.Components.Pages.Audit.ConfigurationAuditLog);
+ var attributes = configType
+ .GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
+ .Cast()
+ .ToList();
+
+ Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Test 2 + 3: Export button visibility split.
+ // ─────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void WithOperationalAudit_NoAuditExport_PageRenders_ExportButtonHidden()
+ {
+ // The "Audit" role grants OperationalAudit + AuditExport in the
+ // default mapping, so we test the split by handing the user ONLY
+ // an extra-narrow role that we map ONLY to OperationalAudit: a
+ // fresh "AuditReadOnly" role (see AuthorizationPolicies).
+ var cut = RenderAuditLogPage("AuditReadOnly");
+
+ cut.WaitForAssertion(() =>
+ {
+ // The page rendered (heading + container present) but the
+ // Export-CSV anchor is gone because AuditExport is denied.
+ Assert.Contains("Audit Log", cut.Markup);
+ Assert.DoesNotContain("Export CSV", cut.Markup);
+ });
+ }
+
+ [Fact]
+ public void WithOperationalAudit_AndAuditExport_PageRenders_ExportButtonVisible()
+ {
+ var cut = RenderAuditLogPage("Audit");
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.Contains("Audit Log", cut.Markup);
+ Assert.Contains("Export CSV", cut.Markup);
+ });
+ }
+
+ [Fact]
+ public void AdminUser_SeesPage_AndExportButton()
+ {
+ // Admin holds every permission by convention — both policies must
+ // succeed for a plain Admin user.
+ var cut = RenderAuditLogPage("Admin");
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.Contains("Audit Log", cut.Markup);
+ Assert.Contains("Export CSV", cut.Markup);
+ });
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Test 4 + 5: Endpoint-level enforcement.
+ // ─────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task AuditExportEndpoint_WithoutAuditExport_Returns403()
+ {
+ // A user holding only Design must NOT be able to call the export
+ // endpoint. Live wiring re-uses AuthorizationPolicies.AuditExport.
+ var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Design" });
+ using (host)
+ {
+ var response = await client.GetAsync("/api/centralui/audit/export");
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ }
+ }
+
+ [Fact]
+ public async Task AuditExportEndpoint_WithAuditExport_Returns200()
+ {
+ var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Audit" });
+ using (host)
+ {
+ var response = await client.GetAsync("/api/centralui/audit/export");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+ }
+
+ [Fact]
+ public async Task AuditExportEndpoint_AdminAlone_Returns200()
+ {
+ // Admin alone (no Audit role) must still pass — defence in depth.
+ var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Admin" });
+ using (host)
+ {
+ var response = await client.GetAsync("/api/centralui/audit/export");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+ }
+
+ [Fact]
+ public async Task AuditExportEndpoint_AuditReadOnly_Returns403()
+ {
+ // AuditReadOnly grants OperationalAudit but NOT AuditExport, so the
+ // endpoint must refuse — the page is readable but the bulk export
+ // path is gated separately.
+ var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "AuditReadOnly" });
+ using (host)
+ {
+ var response = await client.GetAsync("/api/centralui/audit/export");
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Helper: tiny in-process host with the real AuthorizationPolicies.
+ // ─────────────────────────────────────────────────────────────────────
+
+ private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildEndpointHostAsync(
+ string[] roles)
+ {
+ var repo = Substitute.For();
+ repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(
+ Task.FromResult>(Array.Empty()),
+ Task.FromResult>(Array.Empty()));
+
+ var hostBuilder = new HostBuilder()
+ .ConfigureWebHost(web =>
+ {
+ web.UseTestServer();
+ web.ConfigureServices(services =>
+ {
+ services.AddRouting();
+ services.AddAuthentication(FakeAuthHandler.SchemeName)
+ .AddScheme(
+ FakeAuthHandler.SchemeName, opts => opts.Roles = roles);
+ // Real policies — the whole point of these tests is to
+ // exercise the production AddScadaLinkAuthorization wiring.
+ services.AddScadaLinkAuthorization();
+ 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);
+ }
+
+ ///
+ /// Test-only authentication handler that signs every request in with
+ /// the configured set of roles.
+ ///
+ 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 List { new(ClaimTypes.Name, "test-user") };
+ foreach (var role in Options.Roles)
+ {
+ claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
+ }
+ var identity = new ClaimsIdentity(claims, SchemeName);
+ var principal = new ClaimsPrincipal(identity);
+ var ticket = new AuthenticationTicket(principal, SchemeName);
+ return Task.FromResult(AuthenticateResult.Success(ticket));
+ }
+ }
+
+ private sealed class FakeAuthHandlerOptions : AuthenticationSchemeOptions
+ {
+ public string[] Roles { get; set; } = Array.Empty();
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs
new file mode 100644
index 0000000..955c314
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs
@@ -0,0 +1,263 @@
+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;
+
+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)
+ {
+ 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));
+ 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(_queryService);
+
+ if (!string.IsNullOrEmpty(query))
+ {
+ var nav = (BunitNavigationManager)Services.GetRequiredService();
+ nav.NavigateTo($"/audit/log?{query}");
+ }
+
+ // Bundle G (#23 M7-T15): the page now hosts an in-component
+ // AuthorizeView around the Export-CSV button, so the page MUST
+ // render inside a CascadingAuthenticationState. The router supplies
+ // this in production; bUnit hosts the page directly so we wrap it
+ // here.
+ var host = Render(parameters => parameters
+ .Add(p => p.ChildContent, (RenderFragment)(builder =>
+ {
+ builder.OpenComponent(0);
+ builder.CloseComponent();
+ })));
+
+ return host.FindComponent();
+ }
+
+ 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.");
+ });
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 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