Bundle G (#23 M7-T15): replace the temporary Admin-only gate on the Audit Log surface with two new permission policies — OperationalAudit (read) and AuditExport (bulk-export) — so the read path and the forensic-export path can be delegated independently. ScadaLink.Security - AuthorizationPolicies: add OperationalAudit + AuditExport policy constants; register them via RequireClaim with an explicit role allow-list (OperationalAuditRoles, AuditExportRoles) so the role-to-permission mapping is documented in one place. - Default mapping: Admin and Audit roles grant both policies; AuditReadOnly grants OperationalAudit only (read access without bulk export); Design and Deployment grant neither. ScadaLink.CentralUI - AuditLogPage: switch the page-level [Authorize] to the OperationalAudit policy and wrap the Export-CSV button in an AuthorizeView gated on AuditExport so an OperationalAudit-only operator still sees the page + filters but cannot trigger the CSV pull. - ConfigurationAuditLog: switch from RequireAdmin to OperationalAudit so both pages under the Audit nav group share the same gate. - NavMenu: the Audit nav group now gates on OperationalAudit so the section header + both child links match the per-page policies. - AuditExportEndpoints: switch RequireAuthorization from RequireAdmin to AuditExport — this is the authoritative gate; the AuthorizeView on the button is just a UX affordance. Tests - New AuditLogPagePermissionTests covers the 5 brief-mandated cases plus defence-in-depth for Admin-alone and AuditReadOnly users on the endpoint. - SecurityTests: add policy-level coverage for the new role→permission matrix (Theory rows pin every role/policy combination). - AuditExportEndpointsTests: switch to AddScadaLinkAuthorization() so the test host exercises the real production wiring under the new gate. - AuditLogPageScaffoldTests: wrap the page render in a CascadingAuthenticationState so the new in-page AuthorizeView resolves the principal.
279 lines
12 KiB
C#
279 lines
12 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Security.Claims;
|
|
using System.Text.Encodings.Web;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Builder;
|
|
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;
|
|
|
|
namespace ScadaLink.CentralUI.Tests.Audit;
|
|
|
|
/// <summary>
|
|
/// Endpoint-level tests for the Audit Log CSV export (#23 M7-T14 / Bundle F).
|
|
///
|
|
/// <para>
|
|
/// CentralUI uses minimal-API endpoints (see <c>AuthEndpoints</c> /
|
|
/// <c>ScriptAnalysisEndpoints</c>) rather than MVC controllers, so this brief's
|
|
/// "controller" is implemented as <see cref="AuditExportEndpoints"/>. The tests
|
|
/// pin two things: (a) the <c>GET /api/centralui/audit/export</c> route sets
|
|
/// the correct content-type + attachment disposition + body, and (b) the
|
|
/// query-string is parsed into an <see cref="AuditLogQueryFilter"/> and handed
|
|
/// to <see cref="IAuditLogExportService"/>.
|
|
/// </para>
|
|
/// </summary>
|
|
public class AuditExportEndpointsTests
|
|
{
|
|
private static AuditEvent SampleEvent() => new()
|
|
{
|
|
EventId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
|
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
|
IngestedAtUtc = null,
|
|
Channel = AuditChannel.ApiOutbound,
|
|
Kind = AuditKind.ApiCall,
|
|
SourceSiteId = "plant-a",
|
|
Status = AuditStatus.Delivered,
|
|
HttpStatus = 200,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Builds a tiny in-process test host that wires the export endpoint to a
|
|
/// stubbed <see cref="IAuditLogRepository"/>. Returns a ready-to-call
|
|
/// <see cref="HttpClient"/> and the repo substitute so the test can assert
|
|
/// on what the endpoint did.
|
|
/// </summary>
|
|
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync()
|
|
{
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
|
.Returns(
|
|
Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { SampleEvent() }),
|
|
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
|
|
|
var hostBuilder = new HostBuilder()
|
|
.ConfigureWebHost(web =>
|
|
{
|
|
web.UseTestServer();
|
|
web.ConfigureServices(services =>
|
|
{
|
|
services.AddRouting();
|
|
// The endpoint is AuditExport-gated (#23 M7-T15 Bundle G);
|
|
// the tests run as pre-authenticated principals built by
|
|
// FakeAuthHandler (everyone has the Admin role), which is
|
|
// one of AuditExportRoles, so the policy succeeds.
|
|
services.AddAuthentication(FakeAuthHandler.SchemeName)
|
|
.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions, FakeAuthHandler>(
|
|
FakeAuthHandler.SchemeName, _ => { });
|
|
// Use the real production policy wiring so the endpoint's
|
|
// updated AuditExport gate (#23 M7-T15 Bundle G) is what
|
|
// the tests exercise. The fake principal carries the
|
|
// "Admin" role, which AuditExportRoles permits.
|
|
services.AddScadaLinkAuthorization();
|
|
services.AddSingleton(repo);
|
|
services.AddScoped<IAuditLogExportService, AuditLogExportService>();
|
|
});
|
|
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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportEndpoint_Get_ReturnsCsvContentType_AndAttachmentDisposition()
|
|
{
|
|
var (client, _, host) = await BuildHostAsync();
|
|
using (host)
|
|
{
|
|
var response = await client.GetAsync("/api/centralui/audit/export");
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
// Content-Type: text/csv (charset may or may not be present).
|
|
Assert.NotNull(response.Content.Headers.ContentType);
|
|
Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType);
|
|
|
|
// Content-Disposition: attachment with a *.csv filename.
|
|
ContentDispositionHeaderValue? disposition = response.Content.Headers.ContentDisposition;
|
|
Assert.NotNull(disposition);
|
|
Assert.Equal("attachment", disposition!.DispositionType);
|
|
Assert.NotNull(disposition.FileName);
|
|
Assert.EndsWith(".csv", disposition.FileName, StringComparison.OrdinalIgnoreCase);
|
|
|
|
// Body starts with the header row and contains the sample row.
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,", body);
|
|
Assert.Contains("11111111-1111-1111-1111-111111111111", body);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportEndpoint_PassesFilterFromQueryString_ToService()
|
|
{
|
|
var (client, repo, host) = await BuildHostAsync();
|
|
using (host)
|
|
{
|
|
var correlationId = Guid.NewGuid().ToString();
|
|
var url =
|
|
"/api/centralui/audit/export?" +
|
|
"channel=ApiOutbound&" +
|
|
"kind=ApiCall&" +
|
|
"status=Failed&" +
|
|
"site=plant-a&" +
|
|
"target=PaymentApi&" +
|
|
"actor=apikey-1&" +
|
|
$"correlationId={correlationId}&" +
|
|
"from=2026-05-20T00:00:00Z&" +
|
|
"to=2026-05-20T23:59:59Z";
|
|
|
|
var response = await client.GetAsync(url);
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
// Read the body to ensure the streaming response is fully drained
|
|
// before we assert on the repo substitute (the test server flushes
|
|
// the endpoint pipeline on response read).
|
|
_ = await response.Content.ReadAsStringAsync();
|
|
|
|
await repo.Received().QueryAsync(
|
|
Arg.Is<AuditLogQueryFilter>(f =>
|
|
f.Channel == AuditChannel.ApiOutbound &&
|
|
f.Kind == AuditKind.ApiCall &&
|
|
f.Status == AuditStatus.Failed &&
|
|
f.SourceSiteId == "plant-a" &&
|
|
f.Target == "PaymentApi" &&
|
|
f.Actor == "apikey-1" &&
|
|
f.CorrelationId == Guid.Parse(correlationId) &&
|
|
f.FromUtc == new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc) &&
|
|
f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)),
|
|
Arg.Any<AuditLogPaging>(),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportEndpoint_NoQueryString_PassesEmptyFilter()
|
|
{
|
|
// Sanity: a bare GET (no params) yields a filter with every column null
|
|
// — i.e. an unconstrained export.
|
|
var (client, repo, host) = await BuildHostAsync();
|
|
using (host)
|
|
{
|
|
var response = await client.GetAsync("/api/centralui/audit/export");
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
_ = await response.Content.ReadAsStringAsync();
|
|
|
|
await repo.Received().QueryAsync(
|
|
Arg.Is<AuditLogQueryFilter>(f =>
|
|
f.Channel == null &&
|
|
f.Kind == null &&
|
|
f.Status == null &&
|
|
f.SourceSiteId == null &&
|
|
f.Target == null &&
|
|
f.Actor == null &&
|
|
f.CorrelationId == null &&
|
|
f.FromUtc == null &&
|
|
f.ToUtc == null),
|
|
Arg.Any<AuditLogPaging>(),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExportEndpoint_UnknownEnumValue_SilentlyIgnored()
|
|
{
|
|
// Defensive parsing: a junk channel value MUST NOT 500 the export —
|
|
// mirrors the page-level query-string parser (#23 M7 Bundle D) which
|
|
// silently drops unrecognised values.
|
|
var (client, repo, host) = await BuildHostAsync();
|
|
using (host)
|
|
{
|
|
var response = await client.GetAsync("/api/centralui/audit/export?channel=DefinitelyNotAChannel");
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
_ = await response.Content.ReadAsStringAsync();
|
|
|
|
await repo.Received().QueryAsync(
|
|
Arg.Is<AuditLogQueryFilter>(f => f.Channel == null),
|
|
Arg.Any<AuditLogPaging>(),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test-only authentication handler that signs every request in as an Admin.
|
|
/// Admin is in <c>AuditExportRoles</c>, so the endpoint's AuditExport policy
|
|
/// passes without spinning up the real cookie + LDAP pipeline.
|
|
/// </summary>
|
|
private sealed class FakeAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
|
{
|
|
public const string SchemeName = "FakeAuth";
|
|
|
|
public FakeAuthHandler(
|
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
|
ILoggerFactory logger,
|
|
UrlEncoder encoder)
|
|
: base(options, logger, encoder) { }
|
|
|
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
|
{
|
|
var claims = new[]
|
|
{
|
|
new Claim(ClaimTypes.Name, "test-admin"),
|
|
new Claim(JwtTokenService.RoleClaimType, "Admin"),
|
|
};
|
|
var identity = new ClaimsIdentity(claims, SchemeName);
|
|
var principal = new ClaimsPrincipal(identity);
|
|
var ticket = new AuthenticationTicket(principal, SchemeName);
|
|
return Task.FromResult(AuthenticateResult.Success(ticket));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ExportEndpoint_RouteIsRegistered()
|
|
{
|
|
var builder = WebApplication.CreateBuilder();
|
|
builder.Services.AddRouting();
|
|
builder.Services.AddAuthorization();
|
|
builder.Services.AddSingleton(Substitute.For<IAuditLogRepository>());
|
|
builder.Services.AddScoped<IAuditLogExportService, AuditLogExportService>();
|
|
// Dispose the host: an undisposed WebApplication leaks its config
|
|
// PhysicalFileProvider watcher and the ConsoleLoggerProcessor thread.
|
|
using var app = builder.Build();
|
|
app.MapAuditExportEndpoints();
|
|
|
|
var endpoints = ((IEndpointRouteBuilder)app).DataSources
|
|
.SelectMany(ds => ds.Endpoints)
|
|
.OfType<RouteEndpoint>()
|
|
.ToList();
|
|
|
|
var export = endpoints.FirstOrDefault(e =>
|
|
e.RoutePattern.RawText == "/api/centralui/audit/export" &&
|
|
(e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains("GET") ?? false));
|
|
|
|
Assert.NotNull(export);
|
|
}
|
|
}
|