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; /// /// 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. /// public class AuditLogPageScaffoldTests : BunitContext { public AuditLogPageScaffoldTests() { // The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the // column resize/reorder UX via audit-grid.js (a sessionStorage load + // an init call). Loose mode lets those unconfigured JS calls no-op so // the page scaffold smoke tests need not configure browser interop. JSInterop.Mode = JSRuntimeMode.Loose; } private static ClaimsPrincipal BuildPrincipal(params string[] roles) { var claims = new List { new("Username", "tester") }; claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r))); return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); } private IRenderedComponent RenderAuditLogPage(params string[] roles) { return RenderAuditLogPageWithQuery(query: null, roles: roles); } private IAuditLogQueryService _queryService = Substitute.For(); private IRenderedComponent RenderAuditLogPageWithQuery(string? query, params string[] roles) { var user = BuildPrincipal(roles); Services.AddSingleton(new TestAuthStateProvider(user)); Services.AddAuthorizationCore(); AuthorizationPolicies.AddScadaLinkAuthorization(Services); Services.AddSingleton(); // 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()); Services.AddSingleton(_queryService); if (!string.IsNullOrEmpty(query)) { var nav = (BunitNavigationManager)Services.GetRequiredService(); 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(parameters => parameters .Add(p => p.ChildContent, (RenderFragment)(builder => { builder.OpenComponent(0); builder.CloseComponent(); }))); return host.FindComponent(); } private IRenderedComponent RenderNavMenu(params string[] roles) { var user = BuildPrincipal(roles); Services.AddSingleton(new TestAuthStateProvider(user)); Services.AddAuthorizationCore(); AuthorizationPolicies.AddScadaLinkAuthorization(Services); Services.AddSingleton(); var host = Render(parameters => parameters .Add(p => p.ChildContent, (RenderFragment)(builder => { builder.OpenComponent(0); builder.CloseComponent(); }))); return host.FindComponent(); } /// /// Clicks the collapsible section header whose title matches, expanding it. /// Nav sections are collapsed by default, so a section's items are only in /// the DOM once expanded. /// private static void ExpandNavSection(IRenderedComponent cut, string title) { var toggle = cut.FindAll("button.nav-section-toggle") .Single(b => b.TextContent.Contains(title, StringComparison.Ordinal)); toggle.Click(); } [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(" { 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"); ExpandNavSection(cut, "Audit"); 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(); _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(new List())); var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Admin"); cut.WaitForAssertion(() => { // Auto-load fires because correlationId is a real filter dimension. _queryService.Received().QueryAsync( Arg.Is(f => f.CorrelationId == corr), Arg.Any(), Arg.Any()); }); } [Fact] public void NavigateWithExecutionIdParam_AppliesFilter_AndAutoLoads() { // The "View this execution" drill-in lands on /audit/log?executionId={id}. // The page parses the Guid, builds an AuditLogQueryFilter with ExecutionId // set, and auto-loads the grid. var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); _queryService = Substitute.For(); _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(new List())); var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Admin"); cut.WaitForAssertion(() => { _queryService.Received().QueryAsync( Arg.Is(f => f.ExecutionId == executionId), Arg.Any(), Arg.Any()); }); } [Fact] public void NavigateWithUnparseableExecutionIdParam_IsSilentlyDropped_NoAutoLoad() { _queryService = Substitute.For(); var cut = RenderAuditLogPageWithQuery("executionId=not-a-guid", "Admin"); // An unparseable executionId leaves ExecutionId null. With no other filter // params present the page renders but does NOT call the query service. cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup)); _queryService.DidNotReceive().QueryAsync( Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public void NavigateWithParentExecutionIdParam_AppliesFilter_AndAutoLoads() { // The "View parent execution" drill-in (and operator-crafted URLs) land on // /audit/log?parentExecutionId={id}. The page parses the Guid, builds an // AuditLogQueryFilter with ParentExecutionId set, and auto-loads the grid. var parentExecutionId = Guid.Parse("aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb"); _queryService = Substitute.For(); _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(new List())); var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Admin"); cut.WaitForAssertion(() => { _queryService.Received().QueryAsync( Arg.Is(f => f.ParentExecutionId == parentExecutionId), Arg.Any(), Arg.Any()); }); } [Fact] public void NavigateWithUnparseableParentExecutionIdParam_IsSilentlyDropped_NoAutoLoad() { _queryService = Substitute.For(); var cut = RenderAuditLogPageWithQuery("parentExecutionId=not-a-guid", "Admin"); // An unparseable parentExecutionId leaves ParentExecutionId null. With no // other filter params present the page renders but does NOT call the query // service. cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup)); _queryService.DidNotReceive().QueryAsync( Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public void NavigateWithTargetParam_AppliesTargetFilter() { _queryService = Substitute.For(); _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(new List())); var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Admin"); cut.WaitForAssertion(() => { _queryService.Received().QueryAsync( Arg.Is(f => f.Target == "ExternalSystem-Alpha"), Arg.Any(), Arg.Any()); }); } [Fact] public void NavigateWithSiteParam_AppliesSiteFilter() { _queryService = Substitute.For(); _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(new List())); var cut = RenderAuditLogPageWithQuery("site=plant-a", "Admin"); cut.WaitForAssertion(() => { _queryService.Received().QueryAsync( Arg.Is(f => f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a"), Arg.Any(), Arg.Any()); }); } [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(); _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(new List())); var cut = RenderAuditLogPageWithQuery("status=Failed", "Admin"); cut.WaitForAssertion(() => { _queryService.Received().QueryAsync( Arg.Is(f => f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed), Arg.Any(), Arg.Any()); }); } [Fact] public void NavigateWithUnknownStatusParam_IsSilentlyDropped_NoAutoLoad() { _queryService = Substitute.For(); 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(), Arg.Any(), Arg.Any()); } [Fact] public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad() { _queryService = Substitute.For(); 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(), Arg.Any(), Arg.Any()); } }