using System.Net; using System.Security.Claims; using System.Text.Encodings.Web; using Bunit; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NSubstitute; using ScadaLink.CentralUI.Audit; using ScadaLink.CentralUI.Services; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Types.Audit; using ScadaLink.Commons.Types.Enums; using ScadaLink.Security; using AuditLogPage = ScadaLink.CentralUI.Components.Pages.Audit.AuditLogPage; namespace ScadaLink.CentralUI.Tests.Pages; /// /// Permission-gating tests for the Audit Log surface (#23 M7-T15 / Bundle G). /// /// /// Bundle G introduces two new policies: /// /// OperationalAudit — read access to the Audit Log page + /// Configuration Audit Log page + nav group. /// AuditExport — additional gate on the Export-CSV button and /// the streaming export endpoint. /// /// Both policies are satisfied by the Audit role and (defence in depth) /// the Admin role — admins see everything by convention in this /// codebase. The tests pin both the page-level + endpoint-level enforcement, /// and the Export-button visibility split. /// /// public class AuditLogPagePermissionTests : BunitContext { 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 void WireUpPageDependencies() { // The page hosts AuditFilterBar + AuditResultsGrid which depend on // ISiteRepository and IAuditLogQueryService — provide stand-ins so // a permitted render is exercised end-to-end. Services.AddSingleton(Substitute.For()); Services.AddSingleton(Substitute.For()); } private IRenderedComponent RenderAuditLogPage(params string[] roles) { var user = BuildPrincipal(roles); Services.AddSingleton(new TestAuthStateProvider(user)); Services.AddAuthorizationCore(); AuthorizationPolicies.AddScadaLinkAuthorization(Services); Services.AddSingleton(); WireUpPageDependencies(); // Page-level [Authorize(Policy=...)] is enforced by the router in a // live app. bUnit renders the component directly, so we wrap the // page in a CascadingAuthenticationState so the in-page // AuthorizeView for the Export button can read the principal. var host = Render(parameters => parameters .Add(p => p.ChildContent, (RenderFragment)(builder => { builder.OpenComponent(0); builder.CloseComponent(); }))); return host.FindComponent(); } // ───────────────────────────────────────────────────────────────────── // Test 1: WithoutOperationalAudit_PageReturns403_OrHidden // ───────────────────────────────────────────────────────────────────── // // Page-level enforcement is the [Authorize(Policy = "OperationalAudit")] // attribute on the .razor page. We can't easily smoke-test routing here, // so we verify the attribute is present + the policy denies a principal // that holds none of the permitting roles. [Fact] public async Task WithoutOperationalAudit_PolicyDenies() { // A Design-only user (no Audit, no Admin) must NOT satisfy the // OperationalAudit policy. var services = new ServiceCollection(); services.AddLogging(); services.AddScadaLinkAuthorization(); using var provider = services.BuildServiceProvider(); var authService = provider.GetRequiredService(); var principal = BuildPrincipal("Design"); var result = await authService.AuthorizeAsync( principal, null, AuthorizationPolicies.OperationalAudit); Assert.False(result.Succeeded); } [Fact] public void AuditLogPage_HasOperationalAuditAuthorizeAttribute() { // Sanity-pin the attribute so the page-level gate can't regress to // [Authorize] (any-authenticated) by accident. var attributes = typeof(AuditLogPage) .GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true) .Cast() .ToList(); Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit); } [Fact] public void ConfigurationAuditLogPage_HasOperationalAuditAuthorizeAttribute() { // ConfigurationAuditLog mirrors the gate — both Audit-group pages // share the OperationalAudit permission so the nav-group policy // remains coherent with the per-page gates. var configType = typeof(ScadaLink.CentralUI.Components.Pages.Audit.ConfigurationAuditLog); var attributes = configType .GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true) .Cast() .ToList(); Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit); } // ───────────────────────────────────────────────────────────────────── // Test 2 + 3: Export button visibility split. // ───────────────────────────────────────────────────────────────────── [Fact] public void WithOperationalAudit_NoAuditExport_PageRenders_ExportButtonHidden() { // The "Audit" role grants OperationalAudit + AuditExport in the // default mapping, so we test the split by handing the user ONLY // an extra-narrow role that we map ONLY to OperationalAudit: a // fresh "AuditReadOnly" role (see AuthorizationPolicies). var cut = RenderAuditLogPage("AuditReadOnly"); cut.WaitForAssertion(() => { // The page rendered (heading + container present) but the // Export-CSV anchor is gone because AuditExport is denied. Assert.Contains("Audit Log", cut.Markup); Assert.DoesNotContain("Export CSV", cut.Markup); }); } [Fact] public void WithOperationalAudit_AndAuditExport_PageRenders_ExportButtonVisible() { var cut = RenderAuditLogPage("Audit"); cut.WaitForAssertion(() => { Assert.Contains("Audit Log", cut.Markup); Assert.Contains("Export CSV", cut.Markup); }); } [Fact] public void AdminUser_SeesPage_AndExportButton() { // Admin holds every permission by convention — both policies must // succeed for a plain Admin user. var cut = RenderAuditLogPage("Admin"); cut.WaitForAssertion(() => { Assert.Contains("Audit Log", cut.Markup); Assert.Contains("Export CSV", cut.Markup); }); } // ───────────────────────────────────────────────────────────────────── // Test 4 + 5: Endpoint-level enforcement. // ───────────────────────────────────────────────────────────────────── [Fact] public async Task AuditExportEndpoint_WithoutAuditExport_Returns403() { // A user holding only Design must NOT be able to call the export // endpoint. Live wiring re-uses AuthorizationPolicies.AuditExport. var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Design" }); using (host) { var response = await client.GetAsync("/api/centralui/audit/export"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } } [Fact] public async Task AuditExportEndpoint_WithAuditExport_Returns200() { var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Audit" }); using (host) { var response = await client.GetAsync("/api/centralui/audit/export"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } } [Fact] public async Task AuditExportEndpoint_AdminAlone_Returns200() { // Admin alone (no Audit role) must still pass — defence in depth. var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Admin" }); using (host) { var response = await client.GetAsync("/api/centralui/audit/export"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } } [Fact] public async Task AuditExportEndpoint_AuditReadOnly_Returns403() { // AuditReadOnly grants OperationalAudit but NOT AuditExport, so the // endpoint must refuse — the page is readable but the bulk export // path is gated separately. var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "AuditReadOnly" }); using (host) { var response = await client.GetAsync("/api/centralui/audit/export"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } } // ───────────────────────────────────────────────────────────────────── // Helper: tiny in-process host with the real AuthorizationPolicies. // ───────────────────────────────────────────────────────────────────── private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildEndpointHostAsync( string[] roles) { var repo = Substitute.For(); repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns( Task.FromResult>(Array.Empty()), Task.FromResult>(Array.Empty())); var hostBuilder = new HostBuilder() .ConfigureWebHost(web => { web.UseTestServer(); web.ConfigureServices(services => { services.AddRouting(); services.AddAuthentication(FakeAuthHandler.SchemeName) .AddScheme( FakeAuthHandler.SchemeName, opts => opts.Roles = roles); // Real policies — the whole point of these tests is to // exercise the production AddScadaLinkAuthorization wiring. services.AddScadaLinkAuthorization(); services.AddSingleton(repo); services.AddScoped(); }); web.Configure(app => { app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapAuditExportEndpoints(); }); }); }); var host = await hostBuilder.StartAsync(); var client = host.GetTestClient(); return (client, repo, host); } /// /// Test-only authentication handler that signs every request in with /// the configured set of roles. /// private sealed class FakeAuthHandler : AuthenticationHandler { public const string SchemeName = "FakeAuth"; public FakeAuthHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { } protected override Task HandleAuthenticateAsync() { var claims = new List { new(ClaimTypes.Name, "test-user") }; foreach (var role in Options.Roles) { claims.Add(new Claim(JwtTokenService.RoleClaimType, role)); } var identity = new ClaimsIdentity(claims, SchemeName); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, SchemeName); return Task.FromResult(AuthenticateResult.Success(ticket)); } } private sealed class FakeAuthHandlerOptions : AuthenticationSchemeOptions { public string[] Roles { get; set; } = Array.Empty(); } }