feat(mgmt): /api/audit/{query,export} endpoints with permission gates (#23 M8)
This commit is contained in:
386
tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs
Normal file
386
tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs
Normal file
@@ -0,0 +1,386 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.ManagementService;
|
||||
using ScadaLink.Security;
|
||||
|
||||
namespace ScadaLink.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-pipeline tests for the #23 M8 audit endpoints (<see cref="AuditEndpoints"/>).
|
||||
///
|
||||
/// <para>
|
||||
/// ManagementService authenticates each request by hand (HTTP Basic → LDAP →
|
||||
/// roles) rather than via the ASP.NET authorization-policy pipeline, so these
|
||||
/// tests substitute <see cref="LdapAuthService"/> + <see cref="RoleMapper"/>
|
||||
/// (both expose a virtual test seam) to drive the role outcome without standing
|
||||
/// up a directory. The repository is a stubbed <see cref="IAuditLogRepository"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class AuditEndpointsTests
|
||||
{
|
||||
private const string BasicCredential = "auditor:password";
|
||||
|
||||
private static AuditEvent SampleEvent(Guid? id = null, DateTime? occurredAt = null) => new()
|
||||
{
|
||||
EventId = id ?? Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
OccurredAtUtc = occurredAt ?? new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
SourceSiteId = "plant-a",
|
||||
Status = AuditStatus.Delivered,
|
||||
HttpStatus = 200,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds an in-process TestServer hosting the audit endpoints with stubbed
|
||||
/// auth + repository. <paramref name="roles"/> is the role set the
|
||||
/// substituted <see cref="RoleMapper"/> returns for the authenticated user;
|
||||
/// pass an empty array to simulate a user with no audit permission.
|
||||
/// </summary>
|
||||
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync(
|
||||
string[] roles,
|
||||
IReadOnlyList<AuditEvent>[]? queryPages = null,
|
||||
bool ldapSucceeds = true)
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
if (queryPages is { Length: > 0 })
|
||||
{
|
||||
var returns = queryPages
|
||||
.Select(p => Task.FromResult<IReadOnlyList<AuditEvent>>(p))
|
||||
.ToArray();
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(returns[0], returns.Skip(1).ToArray());
|
||||
}
|
||||
else
|
||||
{
|
||||
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||
}
|
||||
|
||||
// Substituted LDAP bind — AuthenticateAsync is virtual (test seam).
|
||||
var ldap = Substitute.For<LdapAuthService>(
|
||||
Options.Create(new SecurityOptions()),
|
||||
Substitute.For<ILogger<LdapAuthService>>());
|
||||
ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ldapSucceeds
|
||||
? new LdapAuthResult(true, "Auditor", "auditor", new[] { "cn=audit" }, null)
|
||||
: new LdapAuthResult(false, null, null, null, "Bad credentials."));
|
||||
|
||||
// Substituted role mapper — MapGroupsToRolesAsync is virtual (test seam).
|
||||
var roleMapper = Substitute.For<RoleMapper>(Substitute.For<ISecurityRepository>());
|
||||
roleMapper.MapGroupsToRolesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new RoleMappingResult(roles, Array.Empty<string>(), IsSystemWideDeployment: true));
|
||||
|
||||
var hostBuilder = new HostBuilder()
|
||||
.ConfigureWebHost(web =>
|
||||
{
|
||||
web.UseTestServer();
|
||||
web.ConfigureServices(services =>
|
||||
{
|
||||
services.AddRouting();
|
||||
services.AddSingleton(repo);
|
||||
services.AddSingleton(ldap);
|
||||
services.AddSingleton(roleMapper);
|
||||
});
|
||||
web.Configure(app =>
|
||||
{
|
||||
app.UseRouting();
|
||||
app.UseEndpoints(endpoints => endpoints.MapAuditAPI());
|
||||
});
|
||||
});
|
||||
|
||||
var host = await hostBuilder.StartAsync();
|
||||
return (host.GetTestClient(), repo, host);
|
||||
}
|
||||
|
||||
private static HttpRequestMessage Get(string url, string credential = BasicCredential)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
if (credential.Length > 0)
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(credential)));
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// /api/audit/query
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Query_ValidParams_ReturnsJsonPage()
|
||||
{
|
||||
var (client, _, host) = await BuildHostAsync(
|
||||
roles: new[] { "Audit" },
|
||||
queryPages: new[] { (IReadOnlyList<AuditEvent>)new[] { SampleEvent() } });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get(
|
||||
"/api/audit/query?channel=ApiOutbound&status=Delivered&pageSize=100"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType);
|
||||
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var events = doc.RootElement.GetProperty("events");
|
||||
Assert.Equal(1, events.GetArrayLength());
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111",
|
||||
events[0].GetProperty("eventId").GetString());
|
||||
|
||||
// A short page (1 row < pageSize 100) means no further pages.
|
||||
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("nextCursor").ValueKind);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_WithCursor_ReturnsNextPage()
|
||||
{
|
||||
// First page is FULL (pageSize=2 → 2 rows) so the response carries a
|
||||
// non-null nextCursor; the test then replays that cursor as the next
|
||||
// request and asserts the repo saw a keyset-paged AuditLogPaging.
|
||||
var pageOne = (IReadOnlyList<AuditEvent>)new[]
|
||||
{
|
||||
SampleEvent(Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"),
|
||||
new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc)),
|
||||
SampleEvent(Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002"),
|
||||
new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc)),
|
||||
};
|
||||
var (client, repo, host) = await BuildHostAsync(
|
||||
roles: new[] { "Audit" },
|
||||
queryPages: new[] { pageOne });
|
||||
using (host)
|
||||
{
|
||||
var first = await client.SendAsync(Get("/api/audit/query?pageSize=2"));
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
|
||||
using var firstDoc = JsonDocument.Parse(await first.Content.ReadAsStringAsync());
|
||||
var cursor = firstDoc.RootElement.GetProperty("nextCursor");
|
||||
Assert.Equal(JsonValueKind.Object, cursor.ValueKind);
|
||||
|
||||
var afterEventId = cursor.GetProperty("afterEventId").GetString()!;
|
||||
var afterOccurredAt = cursor.GetProperty("afterOccurredAtUtc").GetString()!;
|
||||
Assert.Equal("aaaaaaaa-0000-0000-0000-000000000002", afterEventId);
|
||||
|
||||
// Replay the cursor — the endpoint must thread it into AuditLogPaging.
|
||||
var second = await client.SendAsync(Get(
|
||||
$"/api/audit/query?pageSize=2&afterEventId={afterEventId}&afterOccurredAtUtc={Uri.EscapeDataString(afterOccurredAt)}"));
|
||||
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||
|
||||
await repo.Received().QueryAsync(
|
||||
Arg.Any<AuditLogQueryFilter>(),
|
||||
Arg.Is<AuditLogPaging>(p =>
|
||||
p.PageSize == 2 &&
|
||||
p.AfterEventId == Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002") &&
|
||||
p.AfterOccurredAtUtc != null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_WithoutOperationalAudit_Returns403()
|
||||
{
|
||||
// A user whose only role is Design holds neither OperationalAudit nor
|
||||
// AuditExport — the query endpoint must 403.
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Design" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/query"));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_WithoutCredentials_Returns401()
|
||||
{
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/query", credential: ""));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_AuditReadOnlyRole_IsAllowed()
|
||||
{
|
||||
// AuditReadOnly satisfies OperationalAudit (read) — query must succeed.
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/query"));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// /api/audit/export
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Export_Csv_StreamsContent_WithCsvContentType()
|
||||
{
|
||||
var (client, _, host) = await BuildHostAsync(
|
||||
roles: new[] { "Audit" },
|
||||
queryPages: new[]
|
||||
{
|
||||
(IReadOnlyList<AuditEvent>)new[] { SampleEvent() },
|
||||
(IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>(),
|
||||
});
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/export?format=csv"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType);
|
||||
|
||||
var disposition = response.Content.Headers.ContentDisposition;
|
||||
Assert.NotNull(disposition);
|
||||
Assert.Equal("attachment", disposition!.DispositionType);
|
||||
Assert.EndsWith(".csv", disposition.FileName, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,", body);
|
||||
Assert.Contains("11111111-1111-1111-1111-111111111111", body);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_Csv_DefaultsWhenFormatOmitted()
|
||||
{
|
||||
// No format= param → csv default.
|
||||
var (client, _, host) = await BuildHostAsync(
|
||||
roles: new[] { "Audit" },
|
||||
queryPages: new[] { (IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>() });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/export"));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_Jsonl_StreamsOnePerLine()
|
||||
{
|
||||
var (client, _, host) = await BuildHostAsync(
|
||||
roles: new[] { "Audit" },
|
||||
queryPages: new[]
|
||||
{
|
||||
(IReadOnlyList<AuditEvent>)new[]
|
||||
{
|
||||
SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001")),
|
||||
SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002")),
|
||||
},
|
||||
(IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>(),
|
||||
});
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/export?format=jsonl"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/x-ndjson", response.Content.Headers.ContentType!.MediaType);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal(2, lines.Length);
|
||||
|
||||
// Each line must be a standalone, parseable JSON object.
|
||||
foreach (var line in lines)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind);
|
||||
Assert.True(doc.RootElement.TryGetProperty("eventId", out _));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_Parquet_Returns501()
|
||||
{
|
||||
// Parquet archival is deferred to v1.x (Component-AuditLog.md) — no
|
||||
// library is referenced, so the endpoint returns 501 with guidance.
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/export?format=parquet"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Parquet export deferred to v1.x", body);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_WithoutAuditExport_Returns403()
|
||||
{
|
||||
// AuditReadOnly grants read (OperationalAudit) but NOT bulk export
|
||||
// (AuditExport) — the export endpoint must 403.
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/export?format=csv"));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_UnsupportedFormat_Returns400()
|
||||
{
|
||||
var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get("/api/audit/export?format=xml"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Query-string parsing units
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParsePaging_ClampsPageSizeToMax()
|
||||
{
|
||||
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["pageSize"] = "999999",
|
||||
});
|
||||
|
||||
var paging = AuditEndpoints.ParsePaging(query);
|
||||
|
||||
Assert.Equal(AuditEndpoints.MaxPageSize, paging.PageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePaging_HalfSuppliedCursor_IsDropped()
|
||||
{
|
||||
// afterEventId without afterOccurredAtUtc is an invalid keyset cursor —
|
||||
// both must be dropped so the repository gets a first-page request.
|
||||
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["afterEventId"] = Guid.NewGuid().ToString(),
|
||||
});
|
||||
|
||||
var paging = AuditEndpoints.ParsePaging(query);
|
||||
|
||||
Assert.Null(paging.AfterEventId);
|
||||
Assert.Null(paging.AfterOccurredAtUtc);
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,13 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
@@ -20,5 +24,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ScadaLink.ManagementService/ScadaLink.ManagementService.csproj" />
|
||||
<ProjectReference Include="../../src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
<ProjectReference Include="../../src/ScadaLink.Security/ScadaLink.Security.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user