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

@Event.ErrorMessage

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