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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user