Merge branch 'feature/audit-log-m7-central-ui': Audit Log #23 M7 Central UI
M7 ships the user-visible Audit Log surface in the Central UI (Blazor Server + Bootstrap, no third-party UI libraries): - AuditLogPage at /audit/log under a new Audit nav group. - Pre-existing config-change viewer renamed AuditLog.razor -> ConfigurationAuditLog.razor at /audit/configuration. - AuditFilterBar: Channel/Kind/Status/Site chips (Channel narrows Kind), time-range presets + custom range, Instance/Script/Target/Actor text search, Errors-only toggle. - AuditResultsGrid: 10-column custom Bootstrap table, keyset paging (OccurredAtUtc desc, EventId desc), status badges, row-select. - AuditDrilldownDrawer: Bootstrap offcanvas; JSON pretty-print, SQL code block, Copy-as-cURL (ApiOutbound/ApiInbound), Show-all-events by CorrelationId, redaction badges. - Drill-ins: Notifications row link + External Systems / Sites / API Keys / Instances detail-page header links. (Site Calls drill-in deferred — no Site Calls UI page exists yet.) - AuditLogPage query-string filters (correlationId/target/actor/site/ channel/instance) with auto-load. - 3 Health-dashboard KPI tiles: Audit volume, error rate, backlog. - Server-side streaming CSV export via minimal-API endpoint. - OperationalAudit + AuditExport role-claim policies; Audit + new AuditReadOnly roles; page + export + nav gated. - 7 Playwright E2E + bUnit coverage throughout. Fix: AuditLogQueryService now uses scope-per-query (IServiceScopeFactory) so the drill-in auto-load no longer races AuditFilterBar's site query on the shared circuit-scoped DbContext (EF 'second operation' error). Known M7-scope limitations (documented): AuditLogQueryFilter is single-value per dimension, so multi-select chips collapse to the first value; column resize/reorder ships as model + parameter only (no drag UX); SQL highlighting is CSS-class-only (no JS highlighter library). Shipped: 14 commits, ~95 net new tests. CentralUI.Tests 418, Playwright 52. Full solution green (one isolated Host.Tests parallel-runner flake, passes 200/200 in isolation). infra/* untouched on any branch commit.
This commit is contained in:
31
docs/plans/2026-05-20-auditlog-m7-central-ui.md
Normal file
31
docs/plans/2026-05-20-auditlog-m7-central-ui.md
Normal file
@@ -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 `<pre><code>` 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)
|
||||
173
src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs
Normal file
173
src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal-API endpoint hosting the Audit Log CSV export (#23 M7-T14 / Bundle F).
|
||||
///
|
||||
/// <para>
|
||||
/// CentralUI ships no MVC controllers (see <see cref="ScadaLink.CentralUI.Auth.AuthEndpoints"/>
|
||||
/// and <see cref="ScadaLink.CentralUI.ScriptAnalysis.ScriptAnalysisEndpoints"/>),
|
||||
/// so the brief's "controller" is implemented as a minimal-API endpoint instead.
|
||||
/// The endpoint streams to <c>Response.Body</c> directly so the export does NOT
|
||||
/// buffer the full result set in memory — see
|
||||
/// <see cref="IAuditLogExportService.ExportAsync"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The route is gated on the <see cref="AuthorizationPolicies.AuditExport"/>
|
||||
/// policy (#23 M7-T15 / Bundle G) so only roles with the bulk-export
|
||||
/// permission can pull a CSV — the page-level
|
||||
/// <see cref="AuthorizationPolicies.OperationalAudit"/> gate is read-only
|
||||
/// and intentionally narrower. The query-string parser silently drops
|
||||
/// unrecognised values to match the page-level parser in
|
||||
/// <c>AuditLogPage.ApplyQueryStringFilters</c> — an unknown enum value yields
|
||||
/// the same "no constraint" outcome rather than a 400.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AuditExportEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles <c>GET /api/centralui/audit/export</c>. Internal so endpoint
|
||||
/// tests can call it directly when desirable; the live wire-up goes
|
||||
/// through the minimal-API map above.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>.
|
||||
/// Unknown enum names / un-parseable Guids / dates are silently dropped
|
||||
/// (same contract as <c>AuditLogPage.ApplyQueryStringFilters</c>).
|
||||
/// </summary>
|
||||
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
|
||||
{
|
||||
AuditChannel? channel = null;
|
||||
if (query.TryGetValue("channel", out var channelValues)
|
||||
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
|
||||
{
|
||||
channel = parsedChannel;
|
||||
}
|
||||
|
||||
AuditKind? kind = null;
|
||||
if (query.TryGetValue("kind", out var kindValues)
|
||||
&& Enum.TryParse<AuditKind>(kindValues.ToString(), ignoreCase: true, out var parsedKind))
|
||||
{
|
||||
kind = parsedKind;
|
||||
}
|
||||
|
||||
AuditStatus? status = null;
|
||||
if (query.TryGetValue("status", out var statusValues)
|
||||
&& Enum.TryParse<AuditStatus>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional <c>maxRows=</c> query-string override. Falls back to
|
||||
/// <see cref="DefaultMaxRows"/> on a missing / non-positive / unparseable
|
||||
/// value rather than erroring — same lax contract as the rest of the
|
||||
/// query parser.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
<div class="offcanvas-backdrop fade show" data-test="drawer-backdrop"
|
||||
@onclick="HandleClose"></div>
|
||||
<div class="offcanvas offcanvas-end show audit-drilldown-drawer"
|
||||
tabindex="-1"
|
||||
style="visibility: visible;"
|
||||
data-test="audit-drilldown-drawer">
|
||||
<div class="offcanvas-header border-bottom">
|
||||
<div>
|
||||
<div class="text-muted small text-uppercase">Audit event</div>
|
||||
<h5 class="offcanvas-title mb-0">Audit Event @ShortEventId(Event.EventId)</h5>
|
||||
</div>
|
||||
<button type="button" class="btn-close" aria-label="Close"
|
||||
data-test="drawer-close"
|
||||
@onclick="HandleClose"></button>
|
||||
</div>
|
||||
|
||||
<div class="offcanvas-body small">
|
||||
@* Read-only field list — primary identification + provenance. *@
|
||||
<dl class="row mb-3" data-test="drawer-fields">
|
||||
<dt class="col-4 text-muted fw-normal">Channel / Kind</dt>
|
||||
<dd class="col-8" data-test="field-Channel">@Event.Channel / @Event.Kind</dd>
|
||||
|
||||
<dt class="col-4 text-muted fw-normal">Status</dt>
|
||||
<dd class="col-8" data-test="field-Status">@Event.Status</dd>
|
||||
|
||||
<dt class="col-4 text-muted fw-normal">HttpStatus</dt>
|
||||
<dd class="col-8 font-monospace" data-test="field-HttpStatus">@(Event.HttpStatus?.ToString() ?? "—")</dd>
|
||||
|
||||
<dt class="col-4 text-muted fw-normal">Target</dt>
|
||||
<dd class="col-8" data-test="field-Target">@(Event.Target ?? "—")</dd>
|
||||
|
||||
<dt class="col-4 text-muted fw-normal">Actor</dt>
|
||||
<dd class="col-8" data-test="field-Actor">@(Event.Actor ?? "—")</dd>
|
||||
|
||||
<dt class="col-4 text-muted fw-normal">SourceSiteId</dt>
|
||||
<dd class="col-8" data-test="field-SourceSiteId">@(Event.SourceSiteId ?? "—")</dd>
|
||||
|
||||
<dt class="col-4 text-muted fw-normal">SourceInstanceId</dt>
|
||||
<dd class="col-8" data-test="field-SourceInstanceId">@(Event.SourceInstanceId ?? "—")</dd>
|
||||
|
||||
<dt class="col-4 text-muted fw-normal">SourceScript</dt>
|
||||
<dd class="col-8" data-test="field-SourceScript">@(Event.SourceScript ?? "—")</dd>
|
||||
|
||||
<dt class="col-4 text-muted fw-normal">CorrelationId</dt>
|
||||
<dd class="col-8 font-monospace" data-test="field-CorrelationId">@(Event.CorrelationId?.ToString() ?? "—")</dd>
|
||||
|
||||
<dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
|
||||
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
|
||||
|
||||
<dt class="col-4 text-muted fw-normal">IngestedAtUtc</dt>
|
||||
<dd class="col-8 font-monospace" data-test="field-IngestedAtUtc">@(Event.IngestedAtUtc.HasValue ? FormatTimestamp(Event.IngestedAtUtc.Value) : "—")</dd>
|
||||
|
||||
<dt class="col-4 text-muted fw-normal">DurationMs</dt>
|
||||
<dd class="col-8 font-monospace" data-test="field-DurationMs">@(Event.DurationMs?.ToString() ?? "—")</dd>
|
||||
</dl>
|
||||
|
||||
@* Error subsection — only shown when there is something to report. *@
|
||||
@if (!string.IsNullOrEmpty(Event.ErrorMessage) || !string.IsNullOrEmpty(Event.ErrorDetail))
|
||||
{
|
||||
<section class="mb-3" data-test="section-error">
|
||||
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Error</h6>
|
||||
@if (!string.IsNullOrEmpty(Event.ErrorMessage))
|
||||
{
|
||||
<p class="text-danger mb-1">@Event.ErrorMessage</p>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Event.ErrorDetail))
|
||||
{
|
||||
<pre class="bg-light border rounded p-2 mb-0 drawer-pre">@Event.ErrorDetail</pre>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@* Request body (channel-aware renderer). *@
|
||||
@if (!string.IsNullOrEmpty(Event.RequestSummary))
|
||||
{
|
||||
<section class="mb-3" data-test="section-request">
|
||||
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
|
||||
<span>Request</span>
|
||||
@if (IsRedacted(Event.RequestSummary))
|
||||
{
|
||||
<span data-test="redaction-badge-request"
|
||||
class="badge bg-warning text-dark"
|
||||
title="Sensitive values redacted by audit pipeline">
|
||||
Redacted
|
||||
</span>
|
||||
}
|
||||
</h6>
|
||||
<div data-test="request-body">
|
||||
@RenderBody(Event.RequestSummary!, Event.Channel)
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@* Response body (channel-aware renderer). *@
|
||||
@if (!string.IsNullOrEmpty(Event.ResponseSummary))
|
||||
{
|
||||
<section class="mb-3" data-test="section-response">
|
||||
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
|
||||
<span>Response</span>
|
||||
@if (IsRedacted(Event.ResponseSummary))
|
||||
{
|
||||
<span data-test="redaction-badge-response"
|
||||
class="badge bg-warning text-dark"
|
||||
title="Sensitive values redacted by audit pipeline">
|
||||
Redacted
|
||||
</span>
|
||||
}
|
||||
</h6>
|
||||
<div data-test="response-body">
|
||||
@RenderBody(Event.ResponseSummary!, Event.Channel)
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@* Extra is always JSON when present. *@
|
||||
@if (!string.IsNullOrEmpty(Event.Extra))
|
||||
{
|
||||
<section class="mb-3" data-test="section-extra">
|
||||
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Extra</h6>
|
||||
<pre class="bg-light border rounded p-2 mb-0 drawer-pre json">@PrettyPrintJson(Event.Extra!)</pre>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Action buttons at the bottom per form-layout memory. *@
|
||||
<div class="border-top p-3 d-flex gap-2 flex-wrap drawer-footer">
|
||||
@if (IsApiChannel(Event.Channel))
|
||||
{
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-test="copy-as-curl"
|
||||
@onclick="CopyCurl">
|
||||
Copy as cURL
|
||||
</button>
|
||||
}
|
||||
@if (Event.CorrelationId is not null)
|
||||
{
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-test="show-all-events"
|
||||
@onclick="ShowAllForOperation">
|
||||
Show all events for this operation
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-primary btn-sm ms-auto"
|
||||
data-test="drawer-close-footer"
|
||||
@onclick="HandleClose">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8).
|
||||
/// Renders one <see cref="AuditEvent"/> 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.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Body rendering.</b> Request/Response/Extra summaries are strings.
|
||||
/// The drawer pretty-prints JSON when it parses; falls back to verbatim
|
||||
/// otherwise. DbOutbound payloads carry a <c>{sql, parameters}</c> JSON
|
||||
/// shape and get a SQL code block plus a parameter definition list.
|
||||
/// Syntax highlighting is CSS-class-only (<c>language-sql</c>); no JS
|
||||
/// library is loaded — Blazor Server + Bootstrap only per the project's UI
|
||||
/// rules.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Redaction badges.</b> The audit pipeline replaces redacted values
|
||||
/// with the literal sentinels <c><redacted></c> or
|
||||
/// <c><redacted: redactor error></c> (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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Copy as cURL.</b> Best-effort: the URL comes from <c>Target</c>;
|
||||
/// when the RequestSummary parses as <c>{headers, body}</c>, headers are
|
||||
/// folded into <c>-H</c> flags and the body into <c>--data-raw</c>. The
|
||||
/// command is written to the system clipboard via
|
||||
/// <see cref="IJSRuntime.InvokeVoidAsync(string, object?[])"/>. We only
|
||||
/// surface the button for API channels (ApiOutbound / ApiInbound).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Drill-back.</b> When <see cref="AuditEvent.CorrelationId"/> is set,
|
||||
/// the "Show all events" button navigates to
|
||||
/// <c>/audit/log?correlationId={id}</c>. 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class AuditDrilldownDrawer
|
||||
{
|
||||
[Inject] private IJSRuntime JS { get; set; } = null!;
|
||||
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The row to render. When null the drawer renders nothing — the host
|
||||
/// page uses this together with <see cref="IsOpen"/> to drive visibility.
|
||||
/// </summary>
|
||||
[Parameter] public AuditEvent? Event { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the host wants the drawer visible. We deliberately keep
|
||||
/// this as a separate parameter from <see cref="Event"/>: 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.
|
||||
/// </summary>
|
||||
[Parameter] public bool IsOpen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user dismisses the drawer (close button or backdrop
|
||||
/// click). The host is expected to flip <see cref="IsOpen"/> to false.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
|
||||
private const string RedactionSentinel = "<redacted>";
|
||||
private const string RedactorErrorSentinel = "<redacted: redactor error>";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Channel-aware body renderer. DbOutbound bodies that parse as
|
||||
/// <c>{sql, parameters}</c> get a SQL block + parameter list; anything
|
||||
/// else falls back to JSON-pretty-print, then plain-text verbatim.
|
||||
/// </summary>
|
||||
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 <pre> 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<KeyValuePair<string, string>> 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;
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort parse of a DbOutbound <c>{sql, parameters}</c> body.
|
||||
/// Returns true only when the JSON has a string <c>sql</c> property;
|
||||
/// <c>parameters</c> is treated as an optional object whose values
|
||||
/// stringify to scalar text.
|
||||
/// </summary>
|
||||
private static bool TryParseDbBody(string text, out string sql, out List<KeyValuePair<string, string>>? 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<KeyValuePair<string, string>>();
|
||||
foreach (var p in paramsProp.EnumerateObject())
|
||||
{
|
||||
parameters.Add(new KeyValuePair<string, string>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a cURL command from an audit event. The URL comes from
|
||||
/// <c>Target</c>; when the RequestSummary parses as
|
||||
/// <c>{headers, body, method?}</c>, headers fold into <c>-H</c> flags
|
||||
/// and the body into <c>--data-raw</c>. Default method is POST for
|
||||
/// outbound audit rows — the audit pipeline does not always capture
|
||||
/// the verb explicitly.
|
||||
/// </summary>
|
||||
private static string BuildCurlCommand(AuditEvent ev)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("curl");
|
||||
|
||||
string method = "POST";
|
||||
List<KeyValuePair<string, string>>? 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<KeyValuePair<string, string>>? 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<KeyValuePair<string, string>>();
|
||||
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<string, string>(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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quote a single shell argument with single quotes, escaping embedded
|
||||
/// single quotes via the standard <c>'\''</c> idiom. This is the same
|
||||
/// quoting strategy curl examples use across man pages.
|
||||
/// </summary>
|
||||
private static string QuoteShellArg(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "''";
|
||||
var escaped = value.Replace("'", "'\\''", StringComparison.Ordinal);
|
||||
return $"'{escaped}'";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
156
src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor
Normal file
156
src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor
Normal file
@@ -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
|
||||
|
||||
<div class="card mb-3" data-test="audit-filter-bar">
|
||||
<div class="card-body py-2">
|
||||
@* Channel chip multi-select. *@
|
||||
<div class="mb-2" data-test="filter-channel">
|
||||
<label class="form-label small mb-1">Channel</label>
|
||||
<div>
|
||||
@foreach (var channel in Enum.GetValues<AuditChannel>())
|
||||
{
|
||||
var selected = _model.Channels.Contains(channel);
|
||||
<button type="button" data-test="chip-channel-@channel"
|
||||
class="@ChipClass(selected)"
|
||||
@onclick="() => ToggleChannel(channel)">
|
||||
@channel
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Kind chip multi-select — narrowed by Channel selection. *@
|
||||
<div class="mb-2" data-test="filter-kind">
|
||||
<label class="form-label small mb-1">Kind</label>
|
||||
<div>
|
||||
@foreach (var kind in _model.VisibleKinds())
|
||||
{
|
||||
var selected = _model.Kinds.Contains(kind);
|
||||
<button type="button" data-test="chip-kind-@kind"
|
||||
class="@ChipClass(selected)"
|
||||
@onclick="() => ToggleKind(kind)">
|
||||
@kind
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Status chip multi-select. *@
|
||||
<div class="mb-2" data-test="filter-status">
|
||||
<label class="form-label small mb-1">Status</label>
|
||||
<div>
|
||||
@foreach (var status in Enum.GetValues<AuditStatus>())
|
||||
{
|
||||
var selected = _model.Statuses.Contains(status);
|
||||
<button type="button" data-test="chip-status-@status"
|
||||
class="@ChipClass(selected)"
|
||||
@onclick="() => ToggleStatus(status)">
|
||||
@status
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Site chip multi-select — populated from ISiteRepository. *@
|
||||
<div class="mb-2" data-test="filter-site">
|
||||
<label class="form-label small mb-1">Site</label>
|
||||
<div>
|
||||
@if (_sites.Count == 0)
|
||||
{
|
||||
<span class="text-muted small">No sites available.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
var selected = _model.SiteIdentifiers.Contains(site.SiteIdentifier);
|
||||
<button type="button" data-test="chip-site-@site.SiteIdentifier"
|
||||
class="@ChipClass(selected)"
|
||||
@onclick="() => ToggleSite(site.SiteIdentifier)">
|
||||
@site.Name
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-auto" data-test="filter-time-range">
|
||||
<label class="form-label small mb-1" for="audit-time-range">Time range</label>
|
||||
<select id="audit-time-range" class="form-select form-select-sm"
|
||||
@bind="_model.TimeRange">
|
||||
<option value="@AuditTimeRangePreset.Last5Minutes">Last 5 min</option>
|
||||
<option value="@AuditTimeRangePreset.LastHour">Last 1h</option>
|
||||
<option value="@AuditTimeRangePreset.Last24Hours">Last 24h</option>
|
||||
<option value="@AuditTimeRangePreset.Custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@* Custom datetime range; only the pickers are conditional, the wrapper is
|
||||
always emitted so tests can find it. *@
|
||||
<div class="col-auto" data-test="filter-custom-range">
|
||||
@if (_model.TimeRange == AuditTimeRangePreset.Custom)
|
||||
{
|
||||
<div class="d-flex gap-1 align-items-end">
|
||||
<div>
|
||||
<label class="form-label small mb-1" for="audit-from">From (UTC)</label>
|
||||
<input id="audit-from" type="datetime-local" class="form-control form-control-sm"
|
||||
@bind="_model.CustomFromUtc" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label small mb-1" for="audit-to">To (UTC)</label>
|
||||
<input id="audit-to" type="datetime-local" class="form-control form-control-sm"
|
||||
@bind="_model.CustomToUtc" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">Window: @TimeRangeLabel(_model.TimeRange)</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="col-auto" data-test="filter-instance">
|
||||
<label class="form-label small mb-1" for="audit-instance">Instance</label>
|
||||
<input id="audit-instance" type="text" class="form-control form-control-sm"
|
||||
placeholder="contains…" @bind="_model.InstanceSearch" />
|
||||
</div>
|
||||
|
||||
<div class="col-auto" data-test="filter-script">
|
||||
<label class="form-label small mb-1" for="audit-script">Script</label>
|
||||
<input id="audit-script" type="text" class="form-control form-control-sm"
|
||||
placeholder="contains…" @bind="_model.ScriptSearch" />
|
||||
</div>
|
||||
|
||||
<div class="col-auto" data-test="filter-target">
|
||||
<label class="form-label small mb-1" for="audit-target">Target</label>
|
||||
<input id="audit-target" type="text" class="form-control form-control-sm"
|
||||
placeholder="contains…" @bind="_model.TargetSearch" />
|
||||
</div>
|
||||
|
||||
<div class="col-auto" data-test="filter-actor">
|
||||
<label class="form-label small mb-1" for="audit-actor">Actor</label>
|
||||
<input id="audit-actor" type="text" class="form-control form-control-sm"
|
||||
placeholder="contains…" @bind="_model.ActorSearch" />
|
||||
</div>
|
||||
|
||||
<div class="col-auto" data-test="filter-errors-only">
|
||||
<div class="form-check mb-1">
|
||||
<input class="form-check-input" type="checkbox" id="audit-errors-only"
|
||||
@bind="_model.ErrorsOnly" />
|
||||
<label class="form-check-label small" for="audit-errors-only">Errors only</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-auto">
|
||||
<button class="btn btn-outline-secondary btn-sm me-1"
|
||||
@onclick="ClearFilters" data-test="filter-clear">Clear</button>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
@onclick="Apply" data-test="filter-apply">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
144
src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs
Normal file
144
src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
|
||||
/// <see cref="AuditQueryModel"/> binding state, renders the 10 filter elements
|
||||
/// plus the Errors-only toggle, and publishes a collapsed
|
||||
/// <see cref="AuditLogQueryFilter"/> via <see cref="OnFilterChanged"/> when the
|
||||
/// user clicks Apply. See <see cref="AuditQueryModel"/> for the multi-select →
|
||||
/// single-value collapse contract.
|
||||
/// </summary>
|
||||
public partial class AuditFilterBar
|
||||
{
|
||||
private readonly AuditQueryModel _model = new();
|
||||
private List<Site> _sites = new();
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user clicks Apply. Carries the collapsed
|
||||
/// <see cref="AuditLogQueryFilter"/> the parent page hands to
|
||||
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService"/>.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<AuditLogQueryFilter> OnFilterChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="DateTime.UtcNow"/>.
|
||||
/// </summary>
|
||||
[Parameter] public Func<DateTime>? NowUtcProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="AuditLogQueryFilter"/>
|
||||
/// the parent page passes to the grid.
|
||||
/// </summary>
|
||||
[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",
|
||||
_ => "—",
|
||||
};
|
||||
}
|
||||
171
src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs
Normal file
171
src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using System.Collections.Immutable;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.CentralUI.Components.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// UI-side binding model for <see cref="AuditFilterBar"/> (#23 M7-T2).
|
||||
///
|
||||
/// <para>
|
||||
/// The model mirrors <see cref="AuditLogQueryFilter"/> but allows multi-select chip
|
||||
/// state for Channel / Kind / Status / Site (each a <see cref="HashSet{T}"/>) 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The repository filter contract (<see cref="AuditLogQueryFilter"/>) is single-value
|
||||
/// per dimension today; the chip multi-selects therefore collapse to the FIRST
|
||||
/// selected chip when the model is published via <see cref="ToFilter"/>. 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The Errors-only toggle is a convenience: when true AND no explicit Status chips
|
||||
/// are selected, the collapsed filter pins <see cref="AuditStatus.Failed"/> (the
|
||||
/// first of {Failed, Parked, Discarded}). When Status chips ARE selected the toggle
|
||||
/// is a no-op — the explicit Status filter wins.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AuditQueryModel
|
||||
{
|
||||
public HashSet<AuditChannel> Channels { get; } = new();
|
||||
public HashSet<AuditKind> Kinds { get; } = new();
|
||||
public HashSet<AuditStatus> Statuses { get; } = new();
|
||||
public HashSet<string> 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; }
|
||||
|
||||
/// <summary>
|
||||
/// Maps each channel to the kinds it can emit (per Component-AuditLog.md §4).
|
||||
/// <c>CachedSubmit</c> and <c>CachedResolve</c> appear under both
|
||||
/// <see cref="AuditChannel.ApiOutbound"/> and <see cref="AuditChannel.DbOutbound"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<AuditChannel, ImmutableList<AuditKind>> KindsByChannel =
|
||||
new Dictionary<AuditChannel, ImmutableList<AuditKind>>
|
||||
{
|
||||
[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),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public IReadOnlyList<AuditKind> VisibleKinds()
|
||||
{
|
||||
if (Channels.Count == 0)
|
||||
{
|
||||
return Enum.GetValues<AuditKind>();
|
||||
}
|
||||
|
||||
var seen = new HashSet<AuditKind>();
|
||||
var result = new List<AuditKind>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collapses this UI model to the repository's single-value filter.
|
||||
/// See class doc for the multi-select → single-value contract.
|
||||
/// </summary>
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time-range presets surfaced in the filter bar. <see cref="Custom"/> reveals the
|
||||
/// FromUtc / ToUtc datetime pickers; the other presets compute From relative to
|
||||
/// "now" at the moment Apply is clicked.
|
||||
/// </summary>
|
||||
public enum AuditTimeRangePreset
|
||||
{
|
||||
Last5Minutes,
|
||||
LastHour,
|
||||
Last24Hours,
|
||||
Custom,
|
||||
}
|
||||
111
src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor
Normal file
111
src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor
Normal file
@@ -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
|
||||
|
||||
<div data-test="audit-results-grid">
|
||||
@if (_error is not null)
|
||||
{
|
||||
<div class="alert alert-danger small mb-2">@_error</div>
|
||||
}
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
@foreach (var col in OrderedColumns())
|
||||
{
|
||||
<th data-test="col-header-@col.Key">@col.Label</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="@OrderedColumns().Count" class="text-muted small text-center py-4">
|
||||
@if (_loading)
|
||||
{
|
||||
<span>Loading…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>No audit events match the current filter.</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var row in _rows)
|
||||
{
|
||||
<tr @key="row.EventId"
|
||||
data-test="grid-row-@row.EventId"
|
||||
class="audit-row"
|
||||
style="cursor: pointer;"
|
||||
@onclick="() => HandleRowClick(row)">
|
||||
@foreach (var col in OrderedColumns())
|
||||
{
|
||||
<td>
|
||||
@RenderCell(col.Key, row)
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="text-muted small">Page @_pageNumber · @_rows.Count rows</span>
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-test="grid-next-page"
|
||||
disabled="@(_loading || _rows.Count < _pageSize)"
|
||||
@onclick="NextPage">Next page</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private RenderFragment RenderCell(string key, AuditEvent row) => __builder =>
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case "OccurredAtUtc":
|
||||
var occurredOffset = new DateTimeOffset(DateTime.SpecifyKind(row.OccurredAtUtc, DateTimeKind.Utc));
|
||||
<span title="@row.OccurredAtUtc.ToString("u")">
|
||||
<TimestampDisplay Value="occurredOffset" Format="yyyy-MM-dd HH:mm:ss" />
|
||||
</span>
|
||||
break;
|
||||
case "Site":
|
||||
<span class="small">@(row.SourceSiteId ?? "—")</span>
|
||||
break;
|
||||
case "Channel":
|
||||
<span class="small">@row.Channel</span>
|
||||
break;
|
||||
case "Kind":
|
||||
<span class="small">@row.Kind</span>
|
||||
break;
|
||||
case "Status":
|
||||
<span data-test="status-badge-@row.EventId" class="badge @StatusBadgeClass(row.Status)">@row.Status</span>
|
||||
break;
|
||||
case "Target":
|
||||
<span class="small">@(row.Target ?? "—")</span>
|
||||
break;
|
||||
case "Actor":
|
||||
<span class="small">@(row.Actor ?? "—")</span>
|
||||
break;
|
||||
case "DurationMs":
|
||||
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
|
||||
break;
|
||||
case "HttpStatus":
|
||||
<span class="small font-monospace">@(row.HttpStatus?.ToString() ?? "—")</span>
|
||||
break;
|
||||
case "ErrorMessage":
|
||||
<span class="small text-danger" title="@row.ErrorMessage">@TruncateError(row.ErrorMessage)</span>
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Services.IAuditLogQueryService"/>
|
||||
/// — never to <c>IAuditLogRepository</c> directly — so tests can stub the data
|
||||
/// source without standing up EF Core.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Column model.</b> Each column has a stable string key; the visible order
|
||||
/// is the <see cref="ColumnOrder"/> 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 <c>.table-responsive</c> wrapper.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Pagination.</b> Each page is a single call to
|
||||
/// <c>IAuditLogQueryService.QueryAsync</c>. The "Next page" button uses the
|
||||
/// LAST row of the current page as the keyset cursor — repository orders by
|
||||
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>, so the oldest row in the visible
|
||||
/// page becomes <c>AfterOccurredAtUtc</c> + <c>AfterEventId</c> on the next
|
||||
/// request. The button is disabled when the current page is short (less than
|
||||
/// <see cref="PageSize"/> rows) — that's the conventional "we've reached the
|
||||
/// end" signal for keyset paging without a count query.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class AuditResultsGrid
|
||||
{
|
||||
private const int DefaultPageSize = 100;
|
||||
|
||||
private readonly List<AuditEvent> _rows = new();
|
||||
private int _pageNumber = 1;
|
||||
private bool _loading;
|
||||
private string? _error;
|
||||
|
||||
private AuditLogQueryFilter? _activeFilter;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Parameter] public AuditLogQueryFilter? Filter { get; set; }
|
||||
|
||||
/// <summary>Page size. Defaults to 100 to match the service-level default.</summary>
|
||||
[Parameter] public int PageSize { get; set; } = DefaultPageSize;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Parameter] public IReadOnlyList<string>? ColumnOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user clicks a row. Bundle C wires this to the drilldown
|
||||
/// drawer. The event payload is the full <see cref="AuditEvent"/>.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<AuditEvent> OnRowSelected { get; set; }
|
||||
|
||||
// Effective page size used when paging. Mirrors PageSize but bounded > 0.
|
||||
private int _pageSize => Math.Max(1, PageSize);
|
||||
|
||||
/// <summary>
|
||||
/// Default column definitions. The key is the stable identifier (used by
|
||||
/// <c>data-test</c> + the column-order parameter); the label is the user-facing
|
||||
/// header text. Mirrors Component-AuditLog.md §10.
|
||||
/// </summary>
|
||||
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), "…");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="text-muted mb-0">Audit</h6>
|
||||
<a class="small" href="/audit/log">View details →</a>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
@* ── Volume tile ───────────────────────────────────────────────────────── *@
|
||||
<div class="col-lg-4 col-md-6 col-12">
|
||||
<button type="button"
|
||||
class="card h-100 w-100 text-start border-0 shadow-none p-0 audit-kpi-tile"
|
||||
data-test="audit-kpi-volume"
|
||||
@onclick="NavigateToVolume">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0">@VolumeDisplay</h3>
|
||||
<small class="text-muted">Audit volume (last hour)</small>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* ── Error rate tile ───────────────────────────────────────────────────── *@
|
||||
<div class="col-lg-4 col-md-6 col-12">
|
||||
<button type="button"
|
||||
class="card h-100 w-100 text-start border-0 shadow-none p-0 audit-kpi-tile @ErrorRateBorderClass"
|
||||
data-test="audit-kpi-error-rate"
|
||||
@onclick="NavigateToErrors">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0 @ErrorRateTextClass">@ErrorRateDisplay</h3>
|
||||
<small class="text-muted">Audit error rate (last hour)</small>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* ── Backlog tile ──────────────────────────────────────────────────────── *@
|
||||
<div class="col-lg-4 col-md-6 col-12">
|
||||
<button type="button"
|
||||
class="card h-100 w-100 text-start border-0 shadow-none p-0 audit-kpi-tile @BacklogBorderClass"
|
||||
data-test="audit-kpi-backlog"
|
||||
@onclick="NavigateToBacklog">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0 @BacklogTextClass">@BacklogDisplay</h3>
|
||||
<small class="text-muted">Audit backlog (sites pending)</small>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (!IsAvailable && !string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
<div class="text-muted small mb-3">Audit KPIs unavailable: @ErrorMessage</div>
|
||||
}
|
||||
157
src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor.cs
Normal file
157
src/ScadaLink.CentralUI/Components/Health/AuditKpiTiles.razor.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.CentralUI.Components.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) M7 Bundle E (T13) code-behind for <see cref="AuditKpiTiles"/>.
|
||||
/// Renders three KPI tiles — volume, error rate, backlog — from a
|
||||
/// <see cref="AuditLogKpiSnapshot"/> the parent page supplies. Tiles act as
|
||||
/// drill-in links: clicking navigates to <c>/audit/log</c> with the relevant
|
||||
/// query-string filter pre-applied (Bundle D already parses these params).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why purely presentational.</b> 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
|
||||
/// <see cref="AuditLogKpiSnapshot"/> every refresh and the tile component
|
||||
/// re-renders.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Error rate division.</b> When <c>TotalEventsLastHour == 0</c> 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
|
||||
/// <see cref="IsAvailable"/> = <c>false</c>, which represents a failed snapshot
|
||||
/// query (different signal from "quiet hour").
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public partial class AuditKpiTiles
|
||||
{
|
||||
/// <summary>
|
||||
/// Latest KPI snapshot. <c>null</c> means the parent has not loaded it yet
|
||||
/// or the load failed — the tiles render em dashes in that case.
|
||||
/// </summary>
|
||||
[Parameter] public AuditLogKpiSnapshot? Snapshot { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when <see cref="Snapshot"/> 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.
|
||||
/// </summary>
|
||||
[Parameter] public bool IsAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional error message to render underneath the tiles when
|
||||
/// <see cref="IsAvailable"/> is false. Mirrors how the Notification Outbox
|
||||
/// section on the Health dashboard surfaces transient KPI failures.
|
||||
/// </summary>
|
||||
[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 ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -108,11 +108,22 @@
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Audit Log — Admin only *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
@* 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. *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.OperationalAudit">
|
||||
<Authorized Context="auditContext">
|
||||
<div role="presentation" class="nav-section-header">Audit</div>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/monitoring/audit-log">Audit Log</NavLink>
|
||||
<NavLink class="nav-link" href="/audit/log">Audit Log</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/audit/configuration">Configuration Audit Log</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@@ -27,6 +27,17 @@
|
||||
@:Add API Key
|
||||
}
|
||||
</h4>
|
||||
@* 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))
|
||||
{
|
||||
<a class="btn btn-outline-secondary btn-sm ms-auto"
|
||||
href="/audit/log?actor=@Uri.EscapeDataString(_formName)&channel=ApiInbound"
|
||||
data-test="audit-link">
|
||||
Recent audit activity
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@@ -20,7 +20,20 @@
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">@(IsEditMode ? "Edit Site" : "Add Site")</h6>
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<h6 class="card-title">@(IsEditMode ? "Edit Site" : "Add Site")</h6>
|
||||
@* 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))
|
||||
{
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
href="/audit/log?site=@Uri.EscapeDataString(_formIdentifier)"
|
||||
data-test="audit-link">
|
||||
Recent audit activity
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Identifier</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formIdentifier"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
@page "/audit/log"
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
|
||||
@using ScadaLink.CentralUI.Components.Audit
|
||||
@using ScadaLink.CentralUI.Services
|
||||
@using ScadaLink.Commons.Entities.Audit
|
||||
@using ScadaLink.Commons.Types.Audit
|
||||
@using ScadaLink.Security
|
||||
@inject IAuditLogQueryService AuditLogQueryService
|
||||
|
||||
<PageTitle>Audit Log</PageTitle>
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<h1 class="h4 mb-3">Audit Log</h1>
|
||||
|
||||
@* 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. *@
|
||||
<div class="mb-3">
|
||||
<AuditFilterBar OnFilterChanged="HandleFilterChanged"
|
||||
InitialInstanceSearch="@_initialInstanceSearch" />
|
||||
</div>
|
||||
|
||||
@* Export button (Bundle F / M7-T14). A plain <a download> 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. *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.AuditExport">
|
||||
<Authorized Context="exportContext">
|
||||
<div class="mb-3 d-flex justify-content-end">
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
href="@ExportUrl"
|
||||
download
|
||||
role="button"
|
||||
aria-label="Export current view to CSV">
|
||||
Export CSV
|
||||
</a>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* 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. *@
|
||||
<div>
|
||||
<AuditResultsGrid Filter="@_currentFilter" OnRowSelected="HandleRowSelected" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* 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. *@
|
||||
<AuditDrilldownDrawer Event="@_selectedEvent"
|
||||
IsOpen="@_drawerOpen"
|
||||
OnClose="HandleDrawerClose" />
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Code-behind for the central Audit Log page (#23 M7). Bundle B (M7-T2 + M7-T3)
|
||||
/// wires up <c>AuditFilterBar</c> and <c>AuditResultsGrid</c>: the page owns the
|
||||
/// active <see cref="AuditLogQueryFilter"/> 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 <see cref="HandleRowSelected"/> — Bundle C wires
|
||||
/// this to the drilldown drawer; for now it is a no-op seam so test stubs do
|
||||
/// not error.
|
||||
///
|
||||
/// <para>
|
||||
/// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can
|
||||
/// deep-link to a pre-filtered Audit Log: <c>?correlationId=</c>, <c>?target=</c>,
|
||||
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, and the UI-only
|
||||
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
|
||||
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
|
||||
/// drill in to <c>?status=Failed</c>. When any param is present we allocate a
|
||||
/// fresh <see cref="AuditLogQueryFilter"/> and assign it to
|
||||
/// <see cref="_currentFilter"/>, 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<AuditChannel>(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<AuditStatus>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Built here rather than in markup so the per-row test coverage can
|
||||
/// exercise the URL composition without booting the full Blazor renderer.
|
||||
/// </remarks>
|
||||
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<KeyValuePair<string, string?>>(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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<h4 class="mb-3">Audit Log</h4>
|
||||
<h4 class="mb-3">Configuration Audit Log</h4>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@@ -70,10 +70,10 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-6 col-12">
|
||||
<a class="card h-100 text-decoration-none text-reset" href="/monitoring/audit-log">
|
||||
<a class="card h-100 text-decoration-none text-reset" href="/audit/configuration">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<h6 class="mb-1">Recent Audit Log</h6>
|
||||
<h6 class="mb-1">Configuration Audit Log</h6>
|
||||
<span class="text-muted">→</span>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">Browse changes to configuration and deployments.</p>
|
||||
|
||||
@@ -22,6 +22,18 @@
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">← Back to Topology</button>
|
||||
<h4 class="mb-0">Configure Instance</h4>
|
||||
@* 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)
|
||||
{
|
||||
<a class="btn btn-outline-secondary btn-sm ms-auto"
|
||||
href="/audit/log?instance=@Uri.EscapeDataString(_instance.UniqueName)"
|
||||
data-test="audit-link">
|
||||
Recent audit activity
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@@ -10,7 +10,20 @@
|
||||
<div class="container-fluid mt-3">
|
||||
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">← Back</button>
|
||||
|
||||
<h4 class="mb-3">@(Id.HasValue ? "Edit External System" : "Add External System")</h4>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(Id.HasValue ? "Edit External System" : "Add External System")</h4>
|
||||
@* 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))
|
||||
{
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
href="/audit/log?target=@Uri.EscapeDataString(_name)"
|
||||
data-test="audit-link">
|
||||
Recent audit activity
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -56,6 +60,12 @@
|
||||
<div class="text-muted small mb-3">Notification Outbox KPIs unavailable: @_outboxKpiError</div>
|
||||
}
|
||||
|
||||
@* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel
|
||||
(volume / error rate / backlog). Refreshed alongside the site states. *@
|
||||
<AuditKpiTiles Snapshot="@_auditKpi"
|
||||
IsAvailable="@_auditKpiAvailable"
|
||||
ErrorMessage="@_auditKpiError" />
|
||||
|
||||
@if (_siteStates.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No site health reports received yet.</div>
|
||||
@@ -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);
|
||||
|
||||
@@ -163,6 +163,14 @@
|
||||
<td><TimestampDisplay Value="@n.CreatedAt" Format="yyyy-MM-dd HH:mm" /></td>
|
||||
<td><TimestampDisplay Value="@n.DeliveredAt" Format="yyyy-MM-dd HH:mm" NullText="—" /></td>
|
||||
<td class="text-end">
|
||||
@* 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. *@
|
||||
<a class="btn btn-outline-secondary btn-sm me-1"
|
||||
href="/audit/log?correlationId=@n.NotificationId"
|
||||
data-test="audit-link-@n.NotificationId">
|
||||
View audit history
|
||||
</a>
|
||||
@if (n.Status == "Parked")
|
||||
{
|
||||
<button class="btn btn-outline-success btn-sm me-1"
|
||||
@@ -174,10 +182,6 @@
|
||||
Discard
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">—</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -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<TApp>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
|
||||
@@ -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<IDialogService, DialogService>();
|
||||
|
||||
// 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<IAuditLogQueryService>(sp => new AuditLogQueryService(
|
||||
sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
sp.GetRequiredService<ICentralHealthAggregator>()));
|
||||
|
||||
// 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<IAuditLogExportService, AuditLogExportService>();
|
||||
|
||||
// Roslyn-backed C# analysis for the Monaco script editor.
|
||||
// Scoped because SharedScriptCatalog wraps a scoped service.
|
||||
services.AddMemoryCache(o => o.SizeLimit = 200);
|
||||
|
||||
238
src/ScadaLink.CentralUI/Services/AuditLogExportService.cs
Normal file
238
src/ScadaLink.CentralUI/Services/AuditLogExportService.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Streaming CSV exporter for the Audit Log page (#23 M7-T14 / Bundle F).
|
||||
///
|
||||
/// <para>
|
||||
/// The exporter iterates <see cref="IAuditLogRepository.QueryAsync"/> page by page
|
||||
/// using its keyset cursor and writes each row to a destination
|
||||
/// <see cref="Stream"/> 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Output is capped at a caller-supplied <c>maxRows</c> ceiling; when the cap
|
||||
/// is hit the service appends a <c># Capped at … rows. Use the CLI for larger
|
||||
/// exports.</c> footer line so an operator can tell a truncated download from
|
||||
/// a complete one. The header row contains the 21 columns of
|
||||
/// <see cref="AuditEvent"/> in declaration order.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IAuditLogExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Streams a CSV export of the rows matching <paramref name="filter"/> to
|
||||
/// <paramref name="output"/>, capping at <paramref name="maxRows"/>.
|
||||
/// </summary>
|
||||
/// <param name="filter">Repository filter to apply.</param>
|
||||
/// <param name="maxRows">
|
||||
/// Maximum number of data rows (excluding header / footer) to emit. The
|
||||
/// service stops paging once this is reached and appends a cap footer.
|
||||
/// </param>
|
||||
/// <param name="output">Destination stream — typically the HTTP response body.</param>
|
||||
/// <param name="ct">Cancellation token (e.g. <c>HttpContext.RequestAborted</c>).</param>
|
||||
/// <param name="pageSize">
|
||||
/// 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.
|
||||
/// </param>
|
||||
Task ExportAsync(
|
||||
AuditLogQueryFilter filter,
|
||||
int maxRows,
|
||||
Stream output,
|
||||
CancellationToken ct,
|
||||
int pageSize = AuditLogExportService.DefaultPageSize);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IAuditLogExportService"/>
|
||||
public sealed class AuditLogExportService : IAuditLogExportService
|
||||
{
|
||||
/// <summary>Default rows pulled per repository round-trip.</summary>
|
||||
public const int DefaultPageSize = 1000;
|
||||
|
||||
private readonly IAuditLogRepository _repository;
|
||||
|
||||
public AuditLogExportService(IAuditLogRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>The 21 column names in <see cref="AuditEvent"/> declaration order.</summary>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Serialises one <see cref="AuditEvent"/> 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.
|
||||
/// </summary>
|
||||
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' };
|
||||
}
|
||||
135
src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs
Normal file
135
src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IAuditLogQueryService"/> implementation — a thin pass-through
|
||||
/// to <see cref="IAuditLogRepository.QueryAsync"/>. Default page size is 100 (the
|
||||
/// AuditResultsGrid default for #23 M7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// #23 M7 (Bundle H follow-up): each query opens its OWN DI scope and resolves a
|
||||
/// fresh <see cref="IAuditLogRepository"/> — and therefore a fresh
|
||||
/// <c>ScadaLinkDbContext</c> — rather than sharing the scoped Blazor-circuit
|
||||
/// context. Without this, the Audit Log page's query-string auto-load
|
||||
/// (<c>/audit/log?correlationId=…</c>) races <c>AuditFilterBar.GetAllSitesAsync()</c>
|
||||
/// on the single circuit-scoped <c>ScadaLinkDbContext</c>, 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 <c>AuditLogIngestActor</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A second constructor takes an <see cref="IAuditLogRepository"/> directly — a
|
||||
/// test seam (mirroring <c>AuditLogIngestActor</c>'s dual ctor) so unit tests can
|
||||
/// substitute a stub without standing up a DI container.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor — resolves <see cref="IAuditLogRepository"/> from a
|
||||
/// fresh DI scope on every call so each query gets its own
|
||||
/// <c>ScadaLinkDbContext</c> and never contends with the circuit-scoped
|
||||
/// context the filter bar uses.
|
||||
/// </summary>
|
||||
public AuditLogQueryService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ICentralHealthAggregator healthAggregator)
|
||||
{
|
||||
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
||||
_healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-seam constructor — injects a repository instance whose lifetime the
|
||||
/// caller owns. Used by unit tests that substitute a stub repository.
|
||||
/// </summary>
|
||||
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<IReadOnlyList<AuditEvent>> 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<IAuditLogRepository>();
|
||||
return await repository.QueryAsync(filter, effective, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AuditLogKpiSnapshot> 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<IAuditLogRepository>();
|
||||
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 };
|
||||
}
|
||||
}
|
||||
53
src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs
Normal file
53
src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
|
||||
namespace ScadaLink.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// CentralUI facade over <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository"/>
|
||||
/// (#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.
|
||||
/// </summary>
|
||||
public interface IAuditLogQueryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a keyset-paged result page for <paramref name="filter"/>. When
|
||||
/// <paramref name="paging"/> is <c>null</c>, defaults to <see cref="DefaultPageSize"/>
|
||||
/// rows with no cursor (first page). The repository orders by
|
||||
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>; pass the last row's
|
||||
/// <see cref="AuditEvent.OccurredAtUtc"/> + <see cref="AuditEvent.EventId"/>
|
||||
/// back as the cursor for the next page.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
AuditLogQueryFilter filter,
|
||||
AuditLogPaging? paging = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>Default page size when callers don't specify one.</summary>
|
||||
int DefaultPageSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) M7 Bundle E (T13) — returns the point-in-time KPI snapshot
|
||||
/// the Health dashboard's Audit tiles render. Composes:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>TotalEventsLastHour</c> + <c>ErrorEventsLastHour</c> from
|
||||
/// <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.GetKpiSnapshotAsync"/>
|
||||
/// (1-hour trailing window).</item>
|
||||
/// <item><c>BacklogTotal</c> from the sum of every site's
|
||||
/// <c>SiteHealthReport.SiteAuditBacklog.PendingCount</c> via
|
||||
/// <see cref="ScadaLink.HealthMonitoring.ICentralHealthAggregator"/>.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -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<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) M7 Bundle E (T13) — returns aggregate counts over the
|
||||
/// trailing <paramref name="window"/> driving the central Health
|
||||
/// dashboard's Audit KPI tiles.
|
||||
/// </summary>
|
||||
/// <param name="window">
|
||||
/// Trailing time window (e.g. <c>TimeSpan.FromHours(1)</c>). Rows whose
|
||||
/// <c>OccurredAtUtc >= nowUtc - window</c> are counted; the upper
|
||||
/// bound is <paramref name="nowUtc"/>.
|
||||
/// </param>
|
||||
/// <param name="nowUtc">
|
||||
/// Optional explicit "now" timestamp used to anchor the trailing window.
|
||||
/// Defaults to <see cref="DateTime.UtcNow"/> at call time when null —
|
||||
/// production callers should leave this null; tests pin a deterministic
|
||||
/// value so the window is reproducible across runs.
|
||||
/// </param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// A snapshot with <c>TotalEventsLastHour</c> + <c>ErrorEventsLastHour</c>
|
||||
/// populated; <c>BacklogTotal</c> is left at zero (this method has no
|
||||
/// visibility into per-site backlogs — the service layer composes it in
|
||||
/// from <see cref="ScadaLink.HealthMonitoring.ICentralHealthAggregator"/>).
|
||||
/// <c>AsOfUtc</c> is set to the server-side <c>UtcNow</c> at the time of
|
||||
/// the query.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implemented as a single aggregate query
|
||||
/// (<c>SELECT COUNT_BIG(*) AS Total, SUM(CASE …) AS Errors</c>) rather than
|
||||
/// two round trips so the volume + error rate tiles read a consistent
|
||||
/// snapshot — the denominator and numerator come from the same scan.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Errors are defined as <see cref="ScadaLink.Commons.Types.Enums.AuditStatus.Failed"/>,
|
||||
/// <see cref="ScadaLink.Commons.Types.Enums.AuditStatus.Parked"/>, or
|
||||
/// <see cref="ScadaLink.Commons.Types.Enums.AuditStatus.Discarded"/>
|
||||
/// — every non-success terminal lifecycle state. <c>Submitted</c>,
|
||||
/// <c>Forwarded</c>, <c>Attempted</c> are in-flight and are NOT errors;
|
||||
/// <c>Delivered</c> is success; <c>Skipped</c> is an intentional no-op.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window,
|
||||
DateTime? nowUtc = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
38
src/ScadaLink.Commons/Types/AuditLogKpiSnapshot.cs
Normal file
38
src/ScadaLink.Commons/Types/AuditLogKpiSnapshot.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace ScadaLink.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>AuditLog</c> table and combines them
|
||||
/// with the global pending backlog summed across every site's
|
||||
/// <see cref="SiteAuditBacklogSnapshot"/>.
|
||||
/// </summary>
|
||||
/// <param name="TotalEventsLastHour">
|
||||
/// Total <c>AuditLog</c> rows whose <c>OccurredAtUtc</c> 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.
|
||||
/// </param>
|
||||
/// <param name="ErrorEventsLastHour">
|
||||
/// Total <c>AuditLog</c> rows in the same window whose <see cref="Enums.AuditStatus"/>
|
||||
/// is <c>Failed</c>, <c>Parked</c>, or <c>Discarded</c>. Drives the "Audit error
|
||||
/// rate" tile numerator; clicking the tile drills in to <c>/audit/log</c>
|
||||
/// pre-filtered on one of those statuses.
|
||||
/// </param>
|
||||
/// <param name="BacklogTotal">
|
||||
/// Sum of <c>SiteAuditBacklog.PendingCount</c> across every site's latest
|
||||
/// <see cref="ScadaLink.Commons.Messages.Health.SiteHealthReport"/>. Sites whose
|
||||
/// snapshot is <c>null</c> (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.
|
||||
/// </param>
|
||||
/// <param name="AsOfUtc">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public sealed record AuditLogKpiSnapshot(
|
||||
long TotalEventsLastHour,
|
||||
long ErrorEventsLastHour,
|
||||
long BacklogTotal,
|
||||
DateTime AsOfUtc);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M7-T13 Bundle E — Health-dashboard Audit KPI tiles aggregate query.
|
||||
/// Single round-trip
|
||||
/// (<c>SELECT COUNT_BIG(*) AS Total, SUM(CASE WHEN Status IN (...) THEN 1 ELSE 0 END) AS Errors</c>)
|
||||
/// over the trailing <paramref name="window"/> anchored at
|
||||
/// <paramref name="nowUtc"/>. Returns a snapshot with
|
||||
/// <see cref="AuditLogKpiSnapshot.BacklogTotal"/> left at zero — the service
|
||||
/// layer composes that in from
|
||||
/// <see cref="ScadaLink.HealthMonitoring.ICentralHealthAggregator"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// "Error" rows are <c>Failed</c>, <c>Parked</c>, or <c>Discarded</c> — see
|
||||
/// <see cref="IAuditLogRepository.GetKpiSnapshotAsync"/> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<AuditLogKpiSnapshot> 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<string>()).
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,98 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ScadaLink.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Centralised authorization policy names + the role→permission mapping
|
||||
/// that defines them.
|
||||
///
|
||||
/// <para>
|
||||
/// The codebase uses a thin role-claim model: each policy expresses a
|
||||
/// permission, satisfied when the principal carries any role claim
|
||||
/// (<see cref="JwtTokenService.RoleClaimType"/>) that maps to that
|
||||
/// permission. Role names are free strings configured via
|
||||
/// <see cref="ScadaLink.Commons.Entities.Security.LdapGroupMapping"/> rows
|
||||
/// (see <see cref="RoleMapper"/>) — there is no permission claim, just a
|
||||
/// fan-out from role to allowed policies.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Default role → permission mapping (#23 M7-T15 / Bundle G):
|
||||
/// <list type="table">
|
||||
/// <listheader>
|
||||
/// <term>Role</term>
|
||||
/// <description>Policies granted</description>
|
||||
/// </listheader>
|
||||
/// <item>
|
||||
/// <term><c>Admin</c></term>
|
||||
/// <description><see cref="RequireAdmin"/>,
|
||||
/// <see cref="OperationalAudit"/>, <see cref="AuditExport"/> — admins hold
|
||||
/// every permission by convention so an Admin-only user never loses
|
||||
/// access to a new surface.</description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term><c>Design</c></term>
|
||||
/// <description><see cref="RequireDesign"/></description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term><c>Deployment</c></term>
|
||||
/// <description><see cref="RequireDeployment"/></description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term><c>Audit</c></term>
|
||||
/// <description><see cref="OperationalAudit"/>,
|
||||
/// <see cref="AuditExport"/> — the full audit surface (read + bulk
|
||||
/// export) per <c>Component-AuditLog.md</c> §"Authorization".</description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term><c>AuditReadOnly</c></term>
|
||||
/// <description><see cref="OperationalAudit"/> 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.</description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// LDAP group → role mapping is configured via the central UI Admin → LDAP
|
||||
/// Mappings page (rows in <c>LdapGroupMappings</c>); 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AuthorizationPolicies
|
||||
{
|
||||
public const string RequireAdmin = "RequireAdmin";
|
||||
public const string RequireDesign = "RequireDesign";
|
||||
public const string RequireDeployment = "RequireDeployment";
|
||||
|
||||
/// <summary>
|
||||
/// Read access to the Audit Log #23 surface (Audit Log page,
|
||||
/// Configuration Audit Log page, Audit nav group). Granted to the
|
||||
/// <c>Audit</c> role, the <c>AuditReadOnly</c> role, and the
|
||||
/// <c>Admin</c> role.
|
||||
/// </summary>
|
||||
public const string OperationalAudit = "OperationalAudit";
|
||||
|
||||
/// <summary>
|
||||
/// Permission to pull a bulk CSV export of the Audit Log. Separate from
|
||||
/// <see cref="OperationalAudit"/> so a triage operator can read the
|
||||
/// table without being able to exfiltrate it in bulk. Granted to the
|
||||
/// <c>Audit</c> role and the <c>Admin</c> role.
|
||||
/// </summary>
|
||||
public const string AuditExport = "AuditExport";
|
||||
|
||||
/// <summary>
|
||||
/// Roles that satisfy <see cref="OperationalAudit"/>. Held in one place
|
||||
/// so the seed/docs and the policy stay in lockstep.
|
||||
/// </summary>
|
||||
internal static readonly string[] OperationalAuditRoles = { "Admin", "Audit", "AuditReadOnly" };
|
||||
|
||||
/// <summary>
|
||||
/// Roles that satisfy <see cref="AuditExport"/>. A strict subset of
|
||||
/// <see cref="OperationalAuditRoles"/> — read access does NOT imply
|
||||
/// export permission.
|
||||
/// </summary>
|
||||
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<IAuthorizationHandler, SiteScopeAuthorizationHandler>();
|
||||
|
||||
@@ -220,5 +220,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
|
||||
|
||||
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
ThresholdQueries.Add(threshold);
|
||||
return Task.FromResult<IReadOnlyList<DateTime>>(Boundaries.ToArray());
|
||||
}
|
||||
|
||||
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> 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)
|
||||
|
||||
@@ -48,6 +48,9 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -93,6 +93,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||
DateTime threshold, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
|
||||
|
||||
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Direct-SQL seeding helper for the Audit Log Playwright E2E tests (#23 M7-T16).
|
||||
///
|
||||
/// <para>
|
||||
/// The Playwright suite runs against the live Docker cluster (the same one that
|
||||
/// answers <c>http://localhost:9000</c>), which talks to the <c>ScadaLinkConfig</c>
|
||||
/// database on <c>localhost:1433</c>. <c>infra/mssql/seed-config.sql</c> is off
|
||||
/// limits per the task's strict rules, so each test inserts its own
|
||||
/// <c>AuditLog</c> rows at setup time and best-effort deletes them at teardown.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Rows are tagged with a unique <c>Target</c> prefix derived from the test
|
||||
/// name + a GUID so the teardown <c>DELETE</c> never touches rows the cluster
|
||||
/// itself produced. The <c>OccurredAtUtc</c> is pinned to "now" so the default
|
||||
/// <see cref="ScadaLink.CentralUI.Components.Audit.AuditTimeRangePreset.LastHour"/>
|
||||
/// time-range filter still sees the row after Apply.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Connection string mirrors the Docker cluster's <c>scadalink_app</c> account
|
||||
/// from <c>docker/central-node-a/appsettings.Central.json</c>, with the host
|
||||
/// pointed at the host-exposed port (<c>localhost:1433</c>). The
|
||||
/// <c>SCADALINK_PLAYWRIGHT_DB</c> env var lets CI override the connection
|
||||
/// without recompiling.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Connection string for the running cluster's configuration DB. Resolved
|
||||
/// from <c>SCADALINK_PLAYWRIGHT_DB</c> when set, otherwise the local docker
|
||||
/// dev defaults.
|
||||
/// </summary>
|
||||
public static string ConnectionString
|
||||
{
|
||||
get
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable(EnvVar);
|
||||
return string.IsNullOrWhiteSpace(fromEnv) ? DefaultConnectionString : fromEnv;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a single audit row into <c>AuditLog</c>. All optional fields are
|
||||
/// nullable so individual tests can shape the row to whatever payload they
|
||||
/// need for their drawer/grid assertions.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort cleanup. Deletes every <c>AuditLog</c> row whose <c>Target</c>
|
||||
/// starts with <paramref name="targetPrefix"/>. Swallows all errors — a
|
||||
/// stuck row carrying a random GUID suffix does not collide with future
|
||||
/// runs and tests should not fail teardown.
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static async Task<bool> IsAvailableAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for the central Audit Log surface (#23 M7-T16 / Bundle H).
|
||||
///
|
||||
/// <para>
|
||||
/// Each test seeds its own <c>AuditLog</c> rows directly into the running cluster's
|
||||
/// configuration database via <see cref="AuditDataSeeder"/>, exercises the UI
|
||||
/// through Playwright against <c>http://scadalink-traefik</c>, then best-effort
|
||||
/// deletes the rows by their <c>Target</c> prefix. The seed/cleanup pattern keeps
|
||||
/// each test self-contained without touching <c>infra/mssql/seed-config.sql</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Scenarios covered (per the M7-T16 brief):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>FilterNarrowing</c> — channel chip narrows the results grid.</item>
|
||||
/// <item><c>DrilldownDrawer_JsonPrettyPrint</c> — JSON request bodies pretty-print.</item>
|
||||
/// <item><c>CopyAsCurlButton_VisibleOnApiInbound</c> — cURL action visible for API rows.</item>
|
||||
/// <item><c>DrillInFromCorrelationId_AutoLoadsAuditLog</c> — 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).</item>
|
||||
/// <item><c>NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist</c> —
|
||||
/// the report page wires drill-in links when notifications are present.</item>
|
||||
/// <item><c>ExportCsv_LinkIsVisibleAndDownloads</c> — Export CSV button gated on
|
||||
/// the AuditExport policy, click initiates a download.</item>
|
||||
/// <item><c>PermissionGating_DesignerWithoutOperationalAudit_SeesNotAuthorized</c>
|
||||
/// — the page-level <c>[Authorize(Policy = OperationalAudit)]</c> gate blocks a
|
||||
/// Design-only user.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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 <pre> 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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) ───────────────
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Microsoft.Playwright" />
|
||||
<PackageReference Include="xunit" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class ApiKeyFormAuditDrillinTests : BunitContext
|
||||
{
|
||||
private readonly IInboundApiRepository _repo = Substitute.For<IInboundApiRepository>();
|
||||
|
||||
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<AuthenticationStateProvider>(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<CancellationToken>()).Returns(key);
|
||||
_repo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
|
||||
|
||||
var cut = Render<ApiKeyForm>(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<ApiKeyForm>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class SiteFormAuditDrillinTests : BunitContext
|
||||
{
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
private readonly CommunicationService _comms;
|
||||
|
||||
public SiteFormAuditDrillinTests()
|
||||
{
|
||||
_comms = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.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<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
AuthorizationPolicies.AddScadaLinkAuthorization(Services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EditPage_HasRecentAuditActivityLink_WithSiteEqualToSiteIdentifier()
|
||||
{
|
||||
_siteRepo.GetSiteByIdAsync(3, Arg.Any<CancellationToken>())
|
||||
.Returns(new Site("Plant A", "plant-a") { Id = 3 });
|
||||
|
||||
var cut = Render<SiteForm>(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<SiteForm>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint-level tests for the Audit Log CSV export (#23 M7-T14 / Bundle F).
|
||||
///
|
||||
/// <para>
|
||||
/// CentralUI uses minimal-API endpoints (see <c>AuthEndpoints</c> /
|
||||
/// <c>ScriptAnalysisEndpoints</c>) rather than MVC controllers, so this brief's
|
||||
/// "controller" is implemented as <see cref="AuditExportEndpoints"/>. The tests
|
||||
/// pin two things: (a) the <c>GET /api/centralui/audit/export</c> route sets
|
||||
/// the correct content-type + attachment disposition + body, and (b) the
|
||||
/// query-string is parsed into an <see cref="AuditLogQueryFilter"/> and handed
|
||||
/// to <see cref="IAuditLogExportService"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds a tiny in-process test host that wires the export endpoint to a
|
||||
/// stubbed <see cref="IAuditLogRepository"/>. Returns a ready-to-call
|
||||
/// <see cref="HttpClient"/> and the repo substitute so the test can assert
|
||||
/// on what the endpoint did.
|
||||
/// </summary>
|
||||
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { SampleEvent() }),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
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<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions, FakeAuthHandler>(
|
||||
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<IAuditLogExportService, AuditLogExportService>();
|
||||
});
|
||||
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<AuditLogQueryFilter>(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<AuditLogPaging>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
[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<AuditLogQueryFilter>(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<AuditLogPaging>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
[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<AuditLogQueryFilter>(f => f.Channel == null),
|
||||
Arg.Any<AuditLogPaging>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only authentication handler that signs every request in as an Admin.
|
||||
/// Admin is in <c>AuditExportRoles</c>, so the endpoint's AuditExport policy
|
||||
/// passes without spinning up the real cookie + LDAP pipeline.
|
||||
/// </summary>
|
||||
private sealed class FakeAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "FakeAuth";
|
||||
|
||||
public FakeAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder) { }
|
||||
|
||||
protected override Task<AuthenticateResult> 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<IAuditLogRepository>());
|
||||
builder.Services.AddScoped<IAuditLogExportService, AuditLogExportService>();
|
||||
// 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<RouteEndpoint>()
|
||||
.ToList();
|
||||
|
||||
var export = endpoints.FirstOrDefault(e =>
|
||||
e.RoutePattern.RawText == "/api/centralui/audit/export" &&
|
||||
(e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains("GET") ?? false));
|
||||
|
||||
Assert.NotNull(export);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="AuditDrilldownDrawer"/> (#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 <see cref="AuditEvent"/> 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.
|
||||
/// </summary>
|
||||
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<AuditDrilldownDrawer>(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<AuditDrilldownDrawer>(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<AuditDrilldownDrawer>(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<AuditDrilldownDrawer>(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<AuditDrilldownDrawer>(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<AuditDrilldownDrawer>(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<AuditDrilldownDrawer>(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 `<redacted>` (or
|
||||
// `<redacted: redactor error>`) — the drawer must flag it visibly.
|
||||
var ev = MakeEvent(requestSummary: "{\"headers\":{\"Authorization\":\"<redacted>\"},\"body\":\"hello\"}");
|
||||
|
||||
var cut = Render<AuditDrilldownDrawer>(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<AuditDrilldownDrawer>(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<AuditDrilldownDrawer>(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<NavigationManager>();
|
||||
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<AuditDrilldownDrawer>(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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="AuditFilterBar"/> (#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 <c>OnFilterChanged</c>
|
||||
/// with collapsed values; (3) the Channel→Kind narrowing map drives Kind chip
|
||||
/// visibility; (4) the Errors-only toggle ORs <c>Failed</c> into Status when
|
||||
/// Status is otherwise empty; (5) the "Last hour" preset populates
|
||||
/// <c>FromUtc</c> to roughly an hour before "now" — proves the time-window
|
||||
/// collapse without freezing the clock.
|
||||
/// </summary>
|
||||
public class AuditFilterBarTests : BunitContext
|
||||
{
|
||||
private readonly ISiteRepository _siteRepo;
|
||||
|
||||
public AuditFilterBarTests()
|
||||
{
|
||||
_siteRepo = Substitute.For<ISiteRepository>();
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
|
||||
{
|
||||
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<AuditFilterBar>();
|
||||
|
||||
// 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<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(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<AuditFilterBar>();
|
||||
|
||||
// With no Channel selected, every kind chip is in the DOM.
|
||||
foreach (var kind in Enum.GetValues<AuditKind>())
|
||||
{
|
||||
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<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(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<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="AuditResultsGrid"/> (#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.
|
||||
/// </summary>
|
||||
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<IAuditLogQueryService>();
|
||||
_service.DefaultPageSize.Returns(100);
|
||||
Services.AddSingleton(_service);
|
||||
}
|
||||
|
||||
private void StubPage(IReadOnlyList<AuditEvent> rows)
|
||||
{
|
||||
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
_calls.Add(((AuditLogQueryFilter)callInfo[0], (AuditLogPaging?)callInfo[1]));
|
||||
return Task.FromResult(rows);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_TenColumns_FromStubService()
|
||||
{
|
||||
StubPage(new List<AuditEvent>
|
||||
{
|
||||
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
|
||||
});
|
||||
|
||||
var cut = Render<AuditResultsGrid>(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<AuditResultsGrid>(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<AuditResultsGrid>(p => p
|
||||
.Add(c => c.Filter, new AuditLogQueryFilter())
|
||||
.Add(c => c.OnRowSelected, EventCallback.Factory.Create<AuditEvent>(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<AuditResultsGrid>(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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="AuditKpiTiles"/> (#23 M7 Bundle E / M7-T13). The
|
||||
/// component renders three Bootstrap-card tiles — Volume, Error Rate, Backlog —
|
||||
/// from a single <see cref="AuditLogKpiSnapshot"/>. The tests pin:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item>Three-tile render contract (data-test attributes for stable selectors).</item>
|
||||
/// <item>Error-rate maths: <c>ErrorEventsLastHour / TotalEventsLastHour</c> with
|
||||
/// safe zero-events handling (no DivideByZero, displays "0.0%").</item>
|
||||
/// <item>Unavailable snapshot renders em dashes plus the error message.</item>
|
||||
/// <item>Tile clicks navigate to the correct pre-filtered Audit Log URL.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
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<AuditKpiTiles>(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<AuditKpiTiles>(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<AuditKpiTiles>(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<AuditKpiTiles>(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<AuditKpiTiles>(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<NavigationManager>();
|
||||
|
||||
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<AuditKpiTiles>(p => p
|
||||
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 3, backlog: 0))
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
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<AuditKpiTiles>(p => p
|
||||
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 0, backlog: 12))
|
||||
.Add(c => c.IsAvailable, true));
|
||||
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
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<AuditKpiTiles>(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<AuditKpiTiles>(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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>?instance={Instance.UniqueName}</c>. 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.
|
||||
/// </summary>
|
||||
public class InstanceConfigureAuditDrillinTests : BunitContext
|
||||
{
|
||||
private readonly ITemplateEngineRepository _templateRepo =
|
||||
Substitute.For<ITemplateEngineRepository>();
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
|
||||
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<IAuditService>()));
|
||||
Services.AddSingleton(Substitute.For<IFlatteningPipeline>());
|
||||
|
||||
// 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<AuthenticationStateProvider>(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<CancellationToken>()).Returns(instance);
|
||||
_templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new Template("Pump") { Id = 1 });
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Site> { new("Plant A", "plant-a") { Id = 1 } });
|
||||
_templateRepo.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Area>());
|
||||
_templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<TemplateAttribute>());
|
||||
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DataConnection>());
|
||||
_templateRepo.GetBindingsByInstanceIdAsync(42, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<InstanceConnectionBinding>());
|
||||
_templateRepo.GetOverridesByInstanceIdAsync(42, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<InstanceAttributeOverride>());
|
||||
_templateRepo.GetAlarmsByTemplateIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<TemplateAlarm>());
|
||||
_templateRepo.GetAlarmOverridesByInstanceIdAsync(42, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<InstanceAlarmOverride>());
|
||||
|
||||
var cut = Render<InstanceConfigurePage>(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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class ExternalSystemFormAuditDrillinTests : BunitContext
|
||||
{
|
||||
private readonly IExternalSystemRepository _repo = Substitute.For<IExternalSystemRepository>();
|
||||
|
||||
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<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
AuthorizationPolicies.AddScadaLinkAuthorization(Services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EditPage_HasRecentAuditActivityLink_WithTargetEqualToSystemName()
|
||||
{
|
||||
_repo.GetExternalSystemByIdAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(new ExternalSystemDefinition("ERP-Alpha", "https://erp.example.test", "ApiKey")
|
||||
{
|
||||
Id = 7,
|
||||
});
|
||||
|
||||
var cut = Render<ExternalSystemForm>(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<ExternalSystemForm>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AuditLogPage.BuildExportUrl"/> (#23 M7-T14 /
|
||||
/// Bundle F). Builds the <c>?...</c> querystring the Export-CSV link points
|
||||
/// at; the same conversion is round-tripped on the server side by
|
||||
/// <see cref="ScadaLink.CentralUI.Audit.AuditExportEndpoints.ParseFilter"/>.
|
||||
/// These tests pin the no-filter base path + the round-trip back through
|
||||
/// <see cref="QueryHelpers.ParseQuery"/> so the link contract stays stable.
|
||||
/// </summary>
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Permission-gating tests for the Audit Log surface (#23 M7-T15 / Bundle G).
|
||||
///
|
||||
/// <para>
|
||||
/// Bundle G introduces two new policies:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>OperationalAudit</c> — read access to the Audit Log page +
|
||||
/// Configuration Audit Log page + nav group.</item>
|
||||
/// <item><c>AuditExport</c> — additional gate on the Export-CSV button and
|
||||
/// the streaming export endpoint.</item>
|
||||
/// </list>
|
||||
/// Both policies are satisfied by the <c>Audit</c> role and (defence in depth)
|
||||
/// the <c>Admin</c> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class AuditLogPagePermissionTests : BunitContext
|
||||
{
|
||||
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim> { 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<ISiteRepository>());
|
||||
Services.AddSingleton(Substitute.For<IAuditLogQueryService>());
|
||||
}
|
||||
|
||||
private IRenderedComponent<AuditLogPage> RenderAuditLogPage(params string[] roles)
|
||||
{
|
||||
var user = BuildPrincipal(roles);
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
AuthorizationPolicies.AddScadaLinkAuthorization(Services);
|
||||
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||
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<CascadingAuthenticationState>(parameters => parameters
|
||||
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||
{
|
||||
builder.OpenComponent<AuditLogPage>(0);
|
||||
builder.CloseComponent();
|
||||
})));
|
||||
|
||||
return host.FindComponent<AuditLogPage>();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 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<IAuthorizationService>();
|
||||
|
||||
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<AuthorizeAttribute>()
|
||||
.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<AuthorizeAttribute>()
|
||||
.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<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var hostBuilder = new HostBuilder()
|
||||
.ConfigureWebHost(web =>
|
||||
{
|
||||
web.UseTestServer();
|
||||
web.ConfigureServices(services =>
|
||||
{
|
||||
services.AddRouting();
|
||||
services.AddAuthentication(FakeAuthHandler.SchemeName)
|
||||
.AddScheme<FakeAuthHandlerOptions, FakeAuthHandler>(
|
||||
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<IAuditLogExportService, AuditLogExportService>();
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only authentication handler that signs every request in with
|
||||
/// the configured set of roles.
|
||||
/// </summary>
|
||||
private sealed class FakeAuthHandler : AuthenticationHandler<FakeAuthHandlerOptions>
|
||||
{
|
||||
public const string SchemeName = "FakeAuth";
|
||||
|
||||
public FakeAuthHandler(
|
||||
IOptionsMonitor<FakeAuthHandlerOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder) { }
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var claims = new List<Claim> { 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<string>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class AuditLogPageScaffoldTests : BunitContext
|
||||
{
|
||||
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim> { new("Username", "tester") };
|
||||
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
}
|
||||
|
||||
private IRenderedComponent<AuditLogPage> RenderAuditLogPage(params string[] roles)
|
||||
{
|
||||
return RenderAuditLogPageWithQuery(query: null, roles: roles);
|
||||
}
|
||||
|
||||
private IAuditLogQueryService _queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
private IRenderedComponent<AuditLogPage> RenderAuditLogPageWithQuery(string? query, params string[] roles)
|
||||
{
|
||||
var user = BuildPrincipal(roles);
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
AuthorizationPolicies.AddScadaLinkAuthorization(Services);
|
||||
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||
// 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<ScadaLink.Commons.Interfaces.Repositories.ISiteRepository>());
|
||||
Services.AddSingleton(_queryService);
|
||||
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
{
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
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<CascadingAuthenticationState>(parameters => parameters
|
||||
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||
{
|
||||
builder.OpenComponent<AuditLogPage>(0);
|
||||
builder.CloseComponent();
|
||||
})));
|
||||
|
||||
return host.FindComponent<AuditLogPage>();
|
||||
}
|
||||
|
||||
private IRenderedComponent<NavMenu> RenderNavMenu(params string[] roles)
|
||||
{
|
||||
var user = BuildPrincipal(roles);
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
AuthorizationPolicies.AddScadaLinkAuthorization(Services);
|
||||
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||
|
||||
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||
{
|
||||
builder.OpenComponent<NavMenu>(0);
|
||||
builder.CloseComponent();
|
||||
})));
|
||||
|
||||
return host.FindComponent<NavMenu>();
|
||||
}
|
||||
|
||||
[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("<h1", cut.Markup);
|
||||
Assert.Contains("Audit Log", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavMenu_Contains_AuditGroup_With_AuditLog_Link()
|
||||
{
|
||||
var cut = RenderNavMenu("Admin", "Design", "Deployment");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
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<IAuditLogQueryService>();
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Auto-load fires because correlationId is a real filter dimension.
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f => f.CorrelationId == corr),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithTargetParam_AppliesTargetFilter()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f => f.Target == "ExternalSystem-Alpha"),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithSiteParam_AppliesSiteFilter()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("site=plant-a", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f => f.SourceSiteId == "plant-a"),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithStatusParam_AppliesStatusFilter()
|
||||
{
|
||||
// Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills
|
||||
// in with ?status=Failed. The page parses the enum (case-insensitive),
|
||||
// builds an AuditLogQueryFilter with Status set, and auto-loads.
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("status=Failed", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f => f.Status == AuditStatus.Failed),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithUnknownStatusParam_IsSilentlyDropped_NoAutoLoad()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("status=NotARealStatus", "Admin");
|
||||
|
||||
// An unparseable status value leaves Status null. With no other filter
|
||||
// params present the page renders but does NOT call the query service
|
||||
// (matching the existing "no params" contract).
|
||||
cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup));
|
||||
_queryService.DidNotReceive().QueryAsync(
|
||||
Arg.Any<AuditLogQueryFilter>(),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
var cut = RenderAuditLogPage("Admin");
|
||||
|
||||
// The grid is in "no filter" state — the page heading renders, but the
|
||||
// query service must NOT be hit because nothing told us to load.
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("Audit Log", cut.Markup);
|
||||
});
|
||||
|
||||
_queryService.DidNotReceive().QueryAsync(
|
||||
Arg.Any<AuditLogQueryFilter>(),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,11 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ScadaLink.CentralUI.Services;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Messages.Notification;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Communication;
|
||||
using ScadaLink.HealthMonitoring;
|
||||
using HealthPage = ScadaLink.CentralUI.Components.Pages.Monitoring.Health;
|
||||
@@ -55,6 +57,16 @@ public class HealthPageTests : BunitContext
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
|
||||
Services.AddSingleton(siteRepo);
|
||||
|
||||
// Audit Log (#23) M7 Bundle E — the Health page now also fetches the
|
||||
// Audit KPI snapshot. Stub it with an empty point-in-time reading so
|
||||
// the existing assertions (Notification Outbox tiles, Online/Offline
|
||||
// counts) keep passing; tests that target the Audit tiles set their
|
||||
// own substitute.
|
||||
var auditService = Substitute.For<IAuditLogQueryService>();
|
||||
auditService.GetKpiSnapshotAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow)));
|
||||
Services.AddSingleton(auditService);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("Username", "tester"),
|
||||
@@ -92,6 +104,35 @@ public class HealthPageTests : BunitContext
|
||||
Assert.Contains("View details", link.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_AuditKpiTiles_WithValues()
|
||||
{
|
||||
// Override the default empty snapshot — this test wants concrete values
|
||||
// to land in the three Audit tiles.
|
||||
var auditService = Substitute.For<IAuditLogQueryService>();
|
||||
auditService.GetKpiSnapshotAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new AuditLogKpiSnapshot(
|
||||
TotalEventsLastHour: 250,
|
||||
ErrorEventsLastHour: 5,
|
||||
BacklogTotal: 17,
|
||||
AsOfUtc: DateTime.UtcNow)));
|
||||
Services.AddSingleton(auditService);
|
||||
|
||||
var cut = Render<HealthPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// The three audit tiles render at the documented data-test selectors.
|
||||
Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
|
||||
Assert.Contains("data-test=\"audit-kpi-error-rate\"", cut.Markup);
|
||||
Assert.Contains("data-test=\"audit-kpi-backlog\"", cut.Markup);
|
||||
// Volume shows the formatted thousand-separator value.
|
||||
Assert.Contains("250", cut.Markup);
|
||||
// Backlog renders 17.
|
||||
Assert.Contains("17", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutboxKpiFailure_ShowsGracefulFallback()
|
||||
{
|
||||
|
||||
@@ -177,6 +177,62 @@ public class NotificationReportPageTests : BunitContext
|
||||
Assert.Contains("outbox query backend unavailable", cut.Markup));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Bundle D drill-in (#23 M7-T10) — every notification row carries a
|
||||
// "View audit history" link to /audit/log?correlationId={NotificationId}.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NotificationRow_ViewAuditHistory_Link_HasCorrectHref()
|
||||
{
|
||||
var cut = Render<NotificationReportPage>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Both rows (Parked + Delivered) must surface the link — the drill-in
|
||||
// is row-scope, not status-scope. We pin the parked row's href to the
|
||||
// canonical correlationId-deep-link shape.
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
var link = parkedRow.QuerySelector("a[data-test^=\"audit-link-\"]");
|
||||
Assert.NotNull(link);
|
||||
Assert.Equal(
|
||||
"/audit/log?correlationId=notif-aaaaaaaa-1111",
|
||||
link!.GetAttribute("href"));
|
||||
Assert.Contains("View audit history", link.TextContent);
|
||||
|
||||
var deliveredRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Daily summary"));
|
||||
var deliveredLink = deliveredRow.QuerySelector("a[data-test^=\"audit-link-\"]");
|
||||
Assert.NotNull(deliveredLink);
|
||||
Assert.Equal(
|
||||
"/audit/log?correlationId=notif-bbbbbbbb-2222",
|
||||
deliveredLink!.GetAttribute("href"));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Click_NavigatesTo_AuditLog_WithCorrelationId()
|
||||
{
|
||||
// The drill-in is a plain <a href> — browser-native navigation, not a
|
||||
// Blazor onclick handler — so this test verifies the rendered anchor's
|
||||
// attributes are exactly what a browser would follow: href, role, and
|
||||
// human-visible text. (Triggering bUnit's .Click() on a bare anchor
|
||||
// raises MissingEventHandlerException because there is no onclick
|
||||
// handler to invoke; the navigation contract lives in the <a> markup.)
|
||||
var cut = Render<NotificationReportPage>();
|
||||
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||
|
||||
var parkedRow = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
var link = parkedRow.QuerySelector("a[data-test^=\"audit-link-\"]")!;
|
||||
|
||||
Assert.Equal("a", link.TagName, ignoreCase: true);
|
||||
Assert.Equal("/audit/log?correlationId=notif-aaaaaaaa-1111", link.GetAttribute("href"));
|
||||
Assert.Contains("View audit history", link.TextContent);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="bunit" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
@@ -27,6 +29,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj" />
|
||||
<!--
|
||||
The DbContext-race regression test (AuditLogQueryServiceTests) exercises a
|
||||
real ScadaLinkDbContext + AuditLogRepository over SQLite in-memory to prove
|
||||
scope-per-query isolation. Pulls in the ConfigurationDatabase project.
|
||||
-->
|
||||
<ProjectReference Include="../../src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,310 @@
|
||||
using System.Text;
|
||||
using NSubstitute;
|
||||
using ScadaLink.CentralUI.Services;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="AuditLogExportService"/> (#23 M7-T14 / Bundle F). The
|
||||
/// service streams the filtered Audit Log query to a destination stream as
|
||||
/// RFC 4180 CSV. These tests pin:
|
||||
/// <list type="bullet">
|
||||
/// <item>Header + body row count for a simple page.</item>
|
||||
/// <item>RFC 4180 quoting for fields containing commas / quotes / CR-LF.</item>
|
||||
/// <item>Null fields render as empty (no literal "null").</item>
|
||||
/// <item>Row cap honoured + cap footer appended.</item>
|
||||
/// <item>Cancellation tokens propagate mid-stream.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class AuditLogExportServiceTests
|
||||
{
|
||||
private static AuditEvent SimpleEvent(string id, string? target = null, string? error = null)
|
||||
=> new()
|
||||
{
|
||||
EventId = Guid.Parse(id),
|
||||
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||
IngestedAtUtc = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
CorrelationId = null,
|
||||
SourceSiteId = "plant-a",
|
||||
SourceInstanceId = null,
|
||||
SourceScript = null,
|
||||
Actor = null,
|
||||
Target = target,
|
||||
Status = AuditStatus.Delivered,
|
||||
HttpStatus = 200,
|
||||
DurationMs = 42,
|
||||
ErrorMessage = error,
|
||||
ErrorDetail = null,
|
||||
RequestSummary = null,
|
||||
ResponseSummary = null,
|
||||
PayloadTruncated = false,
|
||||
Extra = null,
|
||||
ForwardState = null,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_FiveRows_WritesHeaderPlusFiveRows()
|
||||
{
|
||||
var rows = new List<AuditEvent>
|
||||
{
|
||||
SimpleEvent("11111111-1111-1111-1111-111111111111"),
|
||||
SimpleEvent("22222222-2222-2222-2222-222222222222"),
|
||||
SimpleEvent("33333333-3333-3333-3333-333333333333"),
|
||||
SimpleEvent("44444444-4444-4444-4444-444444444444"),
|
||||
SimpleEvent("55555555-5555-5555-5555-555555555555"),
|
||||
};
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
// First call returns the 5 rows; subsequent calls return empty so the
|
||||
// service terminates the keyset loop.
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(rows),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var sut = new AuditLogExportService(repo);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 100, ms, CancellationToken.None);
|
||||
|
||||
var csv = Encoding.UTF8.GetString(ms.ToArray());
|
||||
var lines = csv.Split("\r\n", StringSplitOptions.None);
|
||||
|
||||
// 1 header + 5 rows + trailing empty token from final \r\n = 7 entries.
|
||||
Assert.Equal(7, lines.Length);
|
||||
Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId,SourceSiteId,", lines[0]);
|
||||
Assert.StartsWith("11111111-1111-1111-1111-111111111111,", lines[1]);
|
||||
Assert.StartsWith("55555555-5555-5555-5555-555555555555,", lines[5]);
|
||||
Assert.Equal(string.Empty, lines[6]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_HeaderHasAll21Columns_InSpecOrder()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var sut = new AuditLogExportService(repo);
|
||||
using var ms = new MemoryStream();
|
||||
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
|
||||
|
||||
var csv = Encoding.UTF8.GetString(ms.ToArray()).TrimEnd('\r', '\n');
|
||||
var header = csv.Split("\r\n")[0];
|
||||
var columns = header.Split(',');
|
||||
|
||||
Assert.Equal(21, columns.Length);
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
"EventId", "OccurredAtUtc", "IngestedAtUtc", "Channel", "Kind",
|
||||
"CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript",
|
||||
"Actor", "Target", "Status", "HttpStatus", "DurationMs",
|
||||
"ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary",
|
||||
"PayloadTruncated", "Extra", "ForwardState",
|
||||
}, columns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_FieldWithComma_QuotedAndEscaped()
|
||||
{
|
||||
// Target contains a comma → field must be wrapped in double quotes.
|
||||
// Target with embedded quote → quote must be doubled ("") and field quoted.
|
||||
// ResponseSummary contains CR-LF → field must be quoted.
|
||||
var row = new AuditEvent
|
||||
{
|
||||
EventId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||
IngestedAtUtc = null,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
CorrelationId = null,
|
||||
SourceSiteId = "plant-a, secondary", // comma
|
||||
SourceInstanceId = null,
|
||||
SourceScript = "say \"hi\"", // embedded quote
|
||||
Actor = null,
|
||||
Target = "x",
|
||||
Status = AuditStatus.Delivered,
|
||||
HttpStatus = null,
|
||||
DurationMs = null,
|
||||
ErrorMessage = "boom\r\nthen again", // CR-LF
|
||||
ErrorDetail = null,
|
||||
RequestSummary = null,
|
||||
ResponseSummary = null,
|
||||
PayloadTruncated = false,
|
||||
Extra = null,
|
||||
ForwardState = null,
|
||||
};
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var sut = new AuditLogExportService(repo);
|
||||
using var ms = new MemoryStream();
|
||||
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
|
||||
|
||||
var csv = Encoding.UTF8.GetString(ms.ToArray());
|
||||
|
||||
// Comma-bearing field is quoted.
|
||||
Assert.Contains("\"plant-a, secondary\"", csv);
|
||||
// Embedded quote is doubled inside a quoted field.
|
||||
Assert.Contains("\"say \"\"hi\"\"\"", csv);
|
||||
// Newline-bearing field is quoted; the inner \r\n stays as-is.
|
||||
Assert.Contains("\"boom\r\nthen again\"", csv);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_NullField_WrittenAsEmpty()
|
||||
{
|
||||
// Build a row with deliberate nulls for every nullable column.
|
||||
var row = new AuditEvent
|
||||
{
|
||||
EventId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||
IngestedAtUtc = null,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
CorrelationId = null,
|
||||
SourceSiteId = null,
|
||||
SourceInstanceId = null,
|
||||
SourceScript = null,
|
||||
Actor = null,
|
||||
Target = null,
|
||||
Status = AuditStatus.Submitted,
|
||||
HttpStatus = null,
|
||||
DurationMs = null,
|
||||
ErrorMessage = null,
|
||||
ErrorDetail = null,
|
||||
RequestSummary = null,
|
||||
ResponseSummary = null,
|
||||
PayloadTruncated = false,
|
||||
Extra = null,
|
||||
ForwardState = null,
|
||||
};
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { row }),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var sut = new AuditLogExportService(repo);
|
||||
using var ms = new MemoryStream();
|
||||
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None);
|
||||
|
||||
var csv = Encoding.UTF8.GetString(ms.ToArray());
|
||||
var dataLine = csv.Split("\r\n")[1];
|
||||
var fields = dataLine.Split(',');
|
||||
|
||||
// EventId(0), OccurredAtUtc(1), IngestedAtUtc(2), Channel(3), Kind(4),
|
||||
// CorrelationId(5), SourceSiteId(6), SourceInstanceId(7), SourceScript(8),
|
||||
// Actor(9), Target(10), Status(11), HttpStatus(12), DurationMs(13),
|
||||
// ErrorMessage(14), ErrorDetail(15), RequestSummary(16), ResponseSummary(17),
|
||||
// PayloadTruncated(18), Extra(19), ForwardState(20)
|
||||
Assert.Equal(21, fields.Length);
|
||||
Assert.Equal("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", fields[0]);
|
||||
Assert.Equal(string.Empty, fields[2]); // IngestedAtUtc null
|
||||
Assert.Equal(string.Empty, fields[5]); // CorrelationId null
|
||||
Assert.Equal(string.Empty, fields[6]); // SourceSiteId null
|
||||
Assert.Equal(string.Empty, fields[12]); // HttpStatus null
|
||||
Assert.Equal(string.Empty, fields[14]); // ErrorMessage null
|
||||
Assert.Equal("False", fields[18]); // PayloadTruncated
|
||||
Assert.Equal(string.Empty, fields[20]); // ForwardState null
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_RowCountAboveCap_Truncates_AppendsCapFooter()
|
||||
{
|
||||
// The service is asked for 3 rows but the repo would happily yield 5.
|
||||
// Output must contain exactly 3 data rows + a footer "# Capped at 3 rows..."
|
||||
var rows = Enumerable.Range(0, 5)
|
||||
.Select(i => SimpleEvent(Guid.NewGuid().ToString()))
|
||||
.ToList();
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
// Repo returns the 5 rows in a single page; the service must stop after 3.
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(rows));
|
||||
|
||||
var sut = new AuditLogExportService(repo);
|
||||
using var ms = new MemoryStream();
|
||||
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 3, ms, CancellationToken.None);
|
||||
|
||||
var csv = Encoding.UTF8.GetString(ms.ToArray());
|
||||
var lines = csv.Split("\r\n", StringSplitOptions.None);
|
||||
|
||||
// 1 header + 3 rows + 1 footer + trailing empty = 6 entries.
|
||||
Assert.Equal(6, lines.Length);
|
||||
Assert.Equal("# Capped at 3 rows. Use the CLI for larger exports.", lines[4]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_CancellationToken_StopsMidStream()
|
||||
{
|
||||
// Repo yields a single page, then on the next page call we observe the
|
||||
// canceled token and throw — service should propagate OperationCanceled.
|
||||
var cts = new CancellationTokenSource();
|
||||
var firstPage = new List<AuditEvent>
|
||||
{
|
||||
SimpleEvent("11111111-1111-1111-1111-111111111111"),
|
||||
SimpleEvent("22222222-2222-2222-2222-222222222222"),
|
||||
};
|
||||
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
// Cancel after delivering the first page so the next loop iteration
|
||||
// sees a canceled token.
|
||||
cts.Cancel();
|
||||
return Task.FromResult<IReadOnlyList<AuditEvent>>(firstPage);
|
||||
});
|
||||
|
||||
var sut = new AuditLogExportService(repo);
|
||||
using var ms = new MemoryStream();
|
||||
|
||||
// The service writes the first page then checks the token before pulling
|
||||
// the next — we expect OperationCanceledException to surface.
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 1000, ms, cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_PaginatesUsingLastRowAsCursor()
|
||||
{
|
||||
// Two pages of 2 rows each, then empty. The service must pass the last
|
||||
// row of page 1 as the cursor on the page-2 call.
|
||||
var p1 = new List<AuditEvent>
|
||||
{
|
||||
new() { EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"), OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
|
||||
new() { EventId = Guid.Parse("22222222-2222-2222-2222-222222222222"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 59, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
|
||||
};
|
||||
var p2 = new List<AuditEvent>
|
||||
{
|
||||
new() { EventId = Guid.Parse("33333333-3333-3333-3333-333333333333"), OccurredAtUtc = new DateTime(2026, 5, 20, 11, 58, 0, DateTimeKind.Utc), Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered },
|
||||
};
|
||||
|
||||
var pagings = new List<AuditLogPaging>();
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => pagings.Add(p)), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(p1),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(p2),
|
||||
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var sut = new AuditLogExportService(repo);
|
||||
using var ms = new MemoryStream();
|
||||
// PageSize is 2 so the first page returns full and the loop continues.
|
||||
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 10, ms, CancellationToken.None, pageSize: 2);
|
||||
|
||||
Assert.True(pagings.Count >= 2, $"Expected at least 2 paged calls, got {pagings.Count}");
|
||||
Assert.Null(pagings[0].AfterEventId);
|
||||
Assert.Null(pagings[0].AfterOccurredAtUtc);
|
||||
Assert.Equal(p1[^1].EventId, pagings[1].AfterEventId);
|
||||
Assert.Equal(p1[^1].OccurredAtUtc, pagings[1].AfterOccurredAtUtc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ScadaLink.CentralUI.Services;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Messages.Health;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||
using ScadaLink.HealthMonitoring;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service-level tests for <see cref="AuditLogQueryService"/> (#23 M7-T3). The
|
||||
/// service is a thin pass-through over <see cref="IAuditLogRepository.QueryAsync"/>;
|
||||
/// these tests pin the filter forwarding contract and the 100-row default-page-size
|
||||
/// rule the grid relies on.
|
||||
/// </summary>
|
||||
public class AuditLogQueryServiceTests
|
||||
{
|
||||
private static ICentralHealthAggregator EmptyAggregator()
|
||||
{
|
||||
var agg = Substitute.For<ICentralHealthAggregator>();
|
||||
agg.GetAllSiteStates().Returns(new Dictionary<string, SiteHealthState>());
|
||||
return agg;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound);
|
||||
var paging = new AuditLogPaging(PageSize: 25);
|
||||
var expected = new List<AuditEvent>
|
||||
{
|
||||
new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered }
|
||||
};
|
||||
repo.QueryAsync(filter, paging, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(expected));
|
||||
|
||||
var sut = new AuditLogQueryService(repo, EmptyAggregator());
|
||||
|
||||
var result = await sut.QueryAsync(filter, paging);
|
||||
|
||||
Assert.Same(expected, result);
|
||||
await repo.Received(1).QueryAsync(filter, paging, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_AppliesDefaultPageSize_WhenNotSpecified()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
AuditLogPaging? observed = null;
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => observed = p), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
|
||||
var sut = new AuditLogQueryService(repo, EmptyAggregator());
|
||||
|
||||
await sut.QueryAsync(new AuditLogQueryFilter(), paging: null);
|
||||
|
||||
Assert.NotNull(observed);
|
||||
Assert.Equal(sut.DefaultPageSize, observed!.PageSize);
|
||||
Assert.Equal(100, sut.DefaultPageSize);
|
||||
Assert.Null(observed.AfterOccurredAtUtc);
|
||||
Assert.Null(observed.AfterEventId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// M7-T13 Bundle E: GetKpiSnapshotAsync — composes repo + health-aggregator
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetKpiSnapshotAsync_ForwardsToRepo_AddsBacklogFromHealthAggregator()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
var anchor = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
var repoSnapshot = new AuditLogKpiSnapshot(
|
||||
TotalEventsLastHour: 42,
|
||||
ErrorEventsLastHour: 7,
|
||||
BacklogTotal: 0, // repo leaves this at zero
|
||||
AsOfUtc: anchor);
|
||||
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(repoSnapshot));
|
||||
|
||||
// Two sites: plant-a with PendingCount=5, plant-b with PendingCount=11.
|
||||
// Sum = 16 → backlog tile shows 16.
|
||||
var sites = new Dictionary<string, SiteHealthState>
|
||||
{
|
||||
["plant-a"] = StateWithBacklog("plant-a", pending: 5),
|
||||
["plant-b"] = StateWithBacklog("plant-b", pending: 11),
|
||||
};
|
||||
var agg = Substitute.For<ICentralHealthAggregator>();
|
||||
agg.GetAllSiteStates().Returns(sites);
|
||||
|
||||
var sut = new AuditLogQueryService(repo, agg);
|
||||
|
||||
var snapshot = await sut.GetKpiSnapshotAsync();
|
||||
|
||||
Assert.Equal(42, snapshot.TotalEventsLastHour);
|
||||
Assert.Equal(7, snapshot.ErrorEventsLastHour);
|
||||
Assert.Equal(16, snapshot.BacklogTotal);
|
||||
Assert.Equal(anchor, snapshot.AsOfUtc);
|
||||
|
||||
// The service requests a 1-hour trailing window and lets the repo
|
||||
// anchor nowUtc to its own clock — we leave the second parameter null.
|
||||
await repo.Received(1).GetKpiSnapshotAsync(
|
||||
TimeSpan.FromHours(1),
|
||||
Arg.Is<DateTime?>(v => v == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetKpiSnapshotAsync_SiteWithoutBacklogSnapshot_ContributesZero()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow)));
|
||||
|
||||
// plant-a has no LatestReport at all; plant-b has a report but null SiteAuditBacklog.
|
||||
var sites = new Dictionary<string, SiteHealthState>
|
||||
{
|
||||
["plant-a"] = new() { SiteId = "plant-a", LatestReport = null, IsOnline = true },
|
||||
["plant-b"] = StateWithBacklog("plant-b", pending: null),
|
||||
["plant-c"] = StateWithBacklog("plant-c", pending: 4),
|
||||
};
|
||||
var agg = Substitute.For<ICentralHealthAggregator>();
|
||||
agg.GetAllSiteStates().Returns(sites);
|
||||
|
||||
var sut = new AuditLogQueryService(repo, agg);
|
||||
|
||||
var snapshot = await sut.GetKpiSnapshotAsync();
|
||||
|
||||
// Only plant-c contributes; plant-a (no report) and plant-b (null backlog) yield zero.
|
||||
Assert.Equal(4, snapshot.BacklogTotal);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// #23 M7 — DbContext concurrency race regression (Bundle H follow-up)
|
||||
//
|
||||
// The drill-in deep link (/audit/log?correlationId=…) triggers an OnInitialized
|
||||
// auto-load query that races AuditFilterBar.GetAllSitesAsync() on the SAME
|
||||
// scoped Blazor-circuit ScadaLinkDbContext. EF Core then throws
|
||||
// "A second operation was started on this context instance before a previous
|
||||
// operation completed." AuditLogQueryService now opens its OWN DI scope per
|
||||
// QueryAsync call (scope-per-query) so it never shares the page's scoped
|
||||
// context — these tests pin that contract.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AuditLogQueryService_ConcurrentQueries_DoNotRace()
|
||||
{
|
||||
// A real ScadaLinkDbContext (SQLite in-memory) registered as SCOPED with
|
||||
// the real AuditLogRepository — exactly the shared-scoped-context shape
|
||||
// that produces the EF race when one context services two operations.
|
||||
using var connection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDbContext<ScadaLinkDbContext>(options =>
|
||||
options.UseSqlite(connection)
|
||||
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)));
|
||||
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
|
||||
// Create the schema once on a throwaway scope.
|
||||
using (var seedScope = provider.CreateScope())
|
||||
{
|
||||
var ctx = seedScope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
await ctx.Database.EnsureCreatedAsync();
|
||||
}
|
||||
|
||||
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
|
||||
var sut = new AuditLogQueryService(scopeFactory, EmptyAggregator());
|
||||
|
||||
var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound);
|
||||
|
||||
// Fire two QueryAsync calls in parallel. With scope-per-query each gets a
|
||||
// fresh DbContext, so this completes cleanly; with a shared scoped context
|
||||
// EF throws "A second operation was started on this context instance".
|
||||
var t1 = sut.QueryAsync(filter);
|
||||
var t2 = sut.QueryAsync(filter);
|
||||
|
||||
var results = await Task.WhenAll(t1, t2);
|
||||
|
||||
Assert.All(results, Assert.NotNull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_OpensFreshScopePerCall_NotSharedAcrossCalls()
|
||||
{
|
||||
// Two sequential calls must each resolve the repository from a distinct
|
||||
// scope — the service must never cache a single repository instance.
|
||||
var resolvedRepos = new List<IAuditLogRepository>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IAuditLogRepository>(_ =>
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
resolvedRepos.Add(repo);
|
||||
return repo;
|
||||
});
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var sut = new AuditLogQueryService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
EmptyAggregator());
|
||||
|
||||
await sut.QueryAsync(new AuditLogQueryFilter());
|
||||
await sut.QueryAsync(new AuditLogQueryFilter());
|
||||
|
||||
// Each QueryAsync opened its own scope → two distinct repo instances.
|
||||
Assert.Equal(2, resolvedRepos.Count);
|
||||
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
|
||||
}
|
||||
|
||||
private static SiteHealthState StateWithBacklog(string siteId, int? pending)
|
||||
{
|
||||
SiteAuditBacklogSnapshot? backlog = pending.HasValue
|
||||
? new SiteAuditBacklogSnapshot(pending.Value, OldestPendingUtc: null, OnDiskBytes: 0)
|
||||
: null;
|
||||
var report = new SiteHealthReport(
|
||||
SiteId: siteId,
|
||||
SequenceNumber: 1,
|
||||
ReportTimestamp: DateTimeOffset.UtcNow,
|
||||
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>(),
|
||||
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
|
||||
ScriptErrorCount: 0,
|
||||
AlarmEvaluationErrorCount: 0,
|
||||
StoreAndForwardBufferDepths: new Dictionary<string, int>(),
|
||||
DeadLetterCount: 0,
|
||||
DeployedInstanceCount: 0,
|
||||
EnabledInstanceCount: 0,
|
||||
DisabledInstanceCount: 0,
|
||||
SiteAuditBacklog: backlog);
|
||||
return new SiteHealthState
|
||||
{
|
||||
SiteId = siteId,
|
||||
LatestReport = report,
|
||||
LastReportReceivedAt = DateTimeOffset.UtcNow,
|
||||
LastHeartbeatAt = DateTimeOffset.UtcNow,
|
||||
LastSequenceNumber = 1,
|
||||
IsOnline = true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -510,6 +510,82 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
Assert.DoesNotContain(new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), boundaries);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// M7-T13 Bundle E: GetKpiSnapshotAsync — Health-dashboard Audit KPI tiles
|
||||
// ------------------------------------------------------------------------
|
||||
//
|
||||
// The dashboard's "Audit volume" tile reads TotalEventsLastHour and the
|
||||
// "Audit error rate" tile reads ErrorEventsLastHour / TotalEventsLastHour.
|
||||
// The repository must (a) count rows whose OccurredAtUtc falls in
|
||||
// [nowUtc - window, nowUtc] and (b) within that scope count rows whose
|
||||
// Status ∈ {Failed, Parked, Discarded} as "error". BacklogTotal is left at
|
||||
// zero here — the service layer composes it in from the health aggregator.
|
||||
//
|
||||
// To keep the test deterministic against the shared fixture DB, each test
|
||||
// pins an obscure-distant nowUtc and seeds rows with OccurredAtUtc inside a
|
||||
// narrow band centred on that anchor — no other test in this class seeds
|
||||
// there, so the global count equals the seeded count for that band.
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GetKpiSnapshotAsync_WithMixedStatusRows_ReturnsCorrectTotalsAndErrors()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
|
||||
// Anchor in November 2026 — no other test in this class seeds there.
|
||||
var nowUtc = new DateTime(2026, 11, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
// Seed 3 success + 1 Failed + 1 Parked + 1 Discarded inside the trailing
|
||||
// 1h window; plus 1 row outside the window that must be excluded.
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-5), status: AuditStatus.Delivered));
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-10), status: AuditStatus.Delivered));
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-15), status: AuditStatus.Delivered));
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-20), status: AuditStatus.Failed));
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-25), status: AuditStatus.Parked));
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-30), status: AuditStatus.Discarded));
|
||||
// Outside-window row (2h before nowUtc).
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddHours(-2), status: AuditStatus.Failed));
|
||||
// Submitted is in-flight, not an "error" — must NOT count toward errors.
|
||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-2), status: AuditStatus.Submitted));
|
||||
|
||||
var snapshot = await repo.GetKpiSnapshotAsync(
|
||||
window: TimeSpan.FromHours(1),
|
||||
nowUtc: nowUtc);
|
||||
|
||||
// 7 rows fall in the trailing 1h window (3 Delivered + 1 Failed + 1 Parked + 1 Discarded + 1 Submitted).
|
||||
// The 2h-before-nowUtc Failed row is excluded by the window.
|
||||
Assert.Equal(7, snapshot.TotalEventsLastHour);
|
||||
// Only Failed/Parked/Discarded count as errors → 3.
|
||||
Assert.Equal(3, snapshot.ErrorEventsLastHour);
|
||||
// The service layer fills BacklogTotal; the repo leaves it at 0.
|
||||
Assert.Equal(0, snapshot.BacklogTotal);
|
||||
// AsOfUtc echoes the anchor.
|
||||
Assert.Equal(nowUtc, snapshot.AsOfUtc);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GetKpiSnapshotAsync_EmptyWindow_ReturnsZeroTotals()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
await using var context = CreateContext();
|
||||
var repo = new AuditLogRepository(context);
|
||||
|
||||
// Anchor in December 2026 — no test seeds there, so the window is empty.
|
||||
var nowUtc = new DateTime(2026, 12, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var snapshot = await repo.GetKpiSnapshotAsync(
|
||||
window: TimeSpan.FromMinutes(1),
|
||||
nowUtc: nowUtc);
|
||||
|
||||
Assert.Equal(0, snapshot.TotalEventsLastHour);
|
||||
Assert.Equal(0, snapshot.ErrorEventsLastHour);
|
||||
Assert.Equal(0, snapshot.BacklogTotal);
|
||||
Assert.Equal(nowUtc, snapshot.AsOfUtc);
|
||||
}
|
||||
|
||||
private async Task<T> ScalarAsync<T>(ScadaLinkDbContext context, string sql)
|
||||
{
|
||||
var conn = context.Database.GetDbConnection();
|
||||
|
||||
@@ -1077,6 +1077,59 @@ public class AuthorizationPolicyTests
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal));
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal));
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal));
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Audit Log #23 — OperationalAudit + AuditExport policies (M7-T15).
|
||||
// Default mapping (see AuthorizationPolicies XML doc):
|
||||
// Admin → OperationalAudit + AuditExport
|
||||
// Audit → OperationalAudit + AuditExport
|
||||
// AuditReadOnly → OperationalAudit only
|
||||
// Design → neither
|
||||
// Deployment → neither
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Admin")]
|
||||
[InlineData("Audit")]
|
||||
[InlineData("AuditReadOnly")]
|
||||
public async Task OperationalAuditPolicy_GrantedRoles_Succeed(string role)
|
||||
{
|
||||
var principal = CreatePrincipal(new[] { role });
|
||||
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Design")]
|
||||
[InlineData("Deployment")]
|
||||
public async Task OperationalAuditPolicy_UngrantedRoles_Fail(string role)
|
||||
{
|
||||
var principal = CreatePrincipal(new[] { role });
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Admin")]
|
||||
[InlineData("Audit")]
|
||||
public async Task AuditExportPolicy_GrantedRoles_Succeed(string role)
|
||||
{
|
||||
var principal = CreatePrincipal(new[] { role });
|
||||
Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("AuditReadOnly")]
|
||||
[InlineData("Design")]
|
||||
[InlineData("Deployment")]
|
||||
public async Task AuditExportPolicy_UngrantedRoles_Fail(string role)
|
||||
{
|
||||
// AuditReadOnly is the load-bearing case: it grants OperationalAudit
|
||||
// (read) but NOT AuditExport (bulk export) — the split that lets a
|
||||
// triage operator drill in without exfiltrating the table.
|
||||
var principal = CreatePrincipal(new[] { role });
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user