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, Guid? executionId = 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, ExecutionId = executionId, 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 void Drawer_NullExecutionId_HidesViewThisExecutionButton() { var ev = MakeEvent(executionId: null); var cut = Render(p => p .Add(c => c.Event, ev) .Add(c => c.IsOpen, true)); Assert.DoesNotContain("data-test=\"view-this-execution\"", cut.Markup); } [Fact] public void Drawer_NonNullExecutionId_ShowsViewThisExecutionButton() { var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-1111-2222-3333-444444444444")); var cut = Render(p => p .Add(c => c.Event, ev) .Add(c => c.IsOpen, true)); Assert.Contains("data-test=\"view-this-execution\"", cut.Markup); } [Fact] public void ViewThisExecution_Navigates_WithExecutionIdQueryString() { var exec = Guid.Parse("dddddddd-cccc-bbbb-aaaa-999999999999"); var ev = MakeEvent(executionId: exec); var cut = Render(p => p .Add(c => c.Event, ev) .Add(c => c.IsOpen, true)); cut.Find("[data-test=\"view-this-execution\"]").Click(); var nav = (BunitNavigationManager)Services.GetRequiredService(); Assert.Contains($"/audit/log?executionId={exec}", 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); } }