feat(ui): AuditDrilldownDrawer with JSON/SQL render, cURL, drill-back, redaction badges (#23 M7)

Implements Bundle C (M7-T4 through M7-T8) of the Audit Log #23 M7
Central UI work: a right-side off-canvas drawer that opens from
AuditResultsGrid row clicks and renders one AuditEvent in full.

Cohesive single-component delivery:
- Read-only fields stacked (form-layout memory): Channel/Kind, Status,
  HttpStatus, Target, Actor, Source* provenance, CorrelationId,
  OccurredAtUtc, IngestedAtUtc, DurationMs.
- Channel-aware body renderer: DbOutbound {sql, parameters} payloads
  render a code-block with CSS-only .language-sql class plus a
  parameter <dl>; other channels JSON-pretty-print when parseable and
  fall back to verbatim <pre>.
- Redaction badges on Request/Response when the body contains the
  <redacted> or <redacted: redactor error> sentinels.
- Copy-as-cURL (API channels only) builds a curl command from Target
  + optional {method, headers, body} RequestSummary JSON and writes
  it via navigator.clipboard.writeText.
- Show-all-events drill-back navigates to /audit/log?correlationId={id}
  when the event carries a CorrelationId.
- Close button + backdrop-click both raise OnClose.

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

11 bUnit tests cover field rendering, JSON pretty-print, verbatim
fallback, SQL block, conditional buttons, redaction badges,
navigation drill-back, and clipboard interop. No third-party UI
libraries: Bootstrap offcanvas + scoped razor.css only.
This commit is contained in:
Joseph Doherty
2026-05-20 20:13:33 -04:00
parent e052aa4ff8
commit ae4480e7aa
6 changed files with 847 additions and 3 deletions

View File

@@ -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>
}

View File

@@ -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>&lt;redacted&gt;</c> or
/// <c>&lt;redacted: redactor error&gt;</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}'";
}
}

View File

@@ -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);
}

View File

@@ -23,3 +23,9 @@
<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" />

View File

@@ -15,6 +15,8 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
public partial class AuditLogPage
{
private AuditLogQueryFilter? _currentFilter;
private AuditEvent? _selectedEvent;
private bool _drawerOpen;
private void HandleFilterChanged(AuditLogQueryFilter filter)
{
@@ -25,8 +27,17 @@ public partial class AuditLogPage
private void HandleRowSelected(AuditEvent row)
{
// Reserved for Bundle C (drilldown drawer). Intentionally left empty: the
// grid still raises the event, but we do nothing with it yet.
_ = row;
// Bundle C: a grid row click hands us the full AuditEvent. We pin it as
// the selected row and open the drilldown drawer — the drawer is fully
// presentational so we do not need to refetch the row.
_selectedEvent = row;
_drawerOpen = true;
}
private void HandleDrawerClose()
{
// We deliberately keep _selectedEvent set so re-opening (e.g. via the
// grid) shows the same row instantly without a re-render flicker.
_drawerOpen = false;
}
}

View File

@@ -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);
}
}