d1fc336e18
GET /api/audit/export?format=parquet returned 501. It now streams the same 24 columns as csv/jsonl (AuditExportRow, CsvHeader order) as one Parquet file, one 10,000-row row group at a time. Each row group is encoded into a small non-seekable staging buffer and copied to the response asynchronously, because Kestrel rejects the synchronous writes the Parquet writer issues. New management commands, with CLI parity: - ListAuditArchives -> `audit archive list` (AuditArchiveListResult: enabled flag + every manifest). - VerifyAuditArchive(Name) -> `audit archive verify --name` (files, chain, purge anchor); exit 0 verified, 3 not verified, 1 error, 2 unauthorized. Both carry verify-chain's gate: OperationalAudit roles (Administrator, Viewer), site-scoped non-Administrator refused because an archive spans every site. VerifyAuditArchive uses the long-running Ask timeout. Error codes ARCHIVE_NOT_CONFIGURED, INVALID_ARGUMENT, NOT_FOUND. RequiredRoleMatrixTests freezes both gates; ManagementSurfaceParityTests now probes `audit archive list|verify` alongside `audit verify-chain`. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
148 lines
6.8 KiB
C#
148 lines
6.8 KiB
C#
using System.Text.Json;
|
|
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
|
|
|
/// <summary>
|
|
/// The <c>ListAuditArchives</c> / <c>VerifyAuditArchive</c> management commands
|
|
/// (ScadaBridge#42): role gate, site-scope refusal, an unconfigured archive directory, bad
|
|
/// names, and the results reaching the wire with enums as names.
|
|
/// </summary>
|
|
public class AuditArchiveCommandTests : TestKit
|
|
{
|
|
private const string Name = "auditlog-2025-10-20261109T120000123Z";
|
|
|
|
private readonly IAuditLogArchive _archive = Substitute.For<IAuditLogArchive>();
|
|
|
|
/// <summary>Configures the archive substitute as a configured, enabled archive.</summary>
|
|
public AuditArchiveCommandTests()
|
|
{
|
|
_archive.IsConfigured.Returns(true);
|
|
_archive.IsEnabled.Returns(true);
|
|
}
|
|
|
|
private IActorRef CreateActor(bool registerArchive = true)
|
|
{
|
|
var services = new ServiceCollection().AddTestConfigurationTransaction();
|
|
if (registerArchive)
|
|
{
|
|
services.AddScoped(_ => _archive);
|
|
}
|
|
|
|
return Sys.ActorOf(Props.Create(() => new ManagementActor(
|
|
services.BuildServiceProvider(), NullLogger<ManagementActor>.Instance)));
|
|
}
|
|
|
|
private static ManagementEnvelope Envelope(object command, string[] sites, params string[] roles) =>
|
|
new(new AuthenticatedUser("u", "U", roles, sites), command, Guid.NewGuid().ToString("N"));
|
|
|
|
private static AuditArchiveManifest Manifest() => new(
|
|
1, Name, new DateTime(2025, 10, 1, 0, 0, 0, DateTimeKind.Utc), "2025-10",
|
|
new DateTime(2026, 11, 9, 12, 0, 0, DateTimeKind.Utc), AuditArchiveStatus.Purged,
|
|
new DateTime(2026, 11, 9, 12, 0, 1, DateTimeKind.Utc), 10, 9, 1, null, null,
|
|
Name + ".parquet", "ab", 1234, Name + ".tombstones.parquet", "cd", 0,
|
|
[new AuditArchiveChainState("2025-10", 9, new string('0', 64), null, null, 0)]);
|
|
|
|
/// <summary>A Viewer lists the archives; status renders as a name.</summary>
|
|
[Fact]
|
|
public void Viewer_ListsArchives()
|
|
{
|
|
_archive.ListAsync(Arg.Any<CancellationToken>()).Returns([Manifest()]);
|
|
|
|
CreateActor().Tell(Envelope(new ListAuditArchivesCommand(), [], "Viewer"));
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
using var json = JsonDocument.Parse(response.JsonData);
|
|
Assert.True(json.RootElement.GetProperty("enabled").GetBoolean());
|
|
var archive = json.RootElement.GetProperty("archives")[0];
|
|
Assert.Equal(Name, archive.GetProperty("name").GetString());
|
|
Assert.Equal("Purged", archive.GetProperty("status").GetString());
|
|
}
|
|
|
|
/// <summary>Verification results reach the wire with the anchor and chain status as names.</summary>
|
|
[Fact]
|
|
public void Viewer_GetsVerificationResult()
|
|
{
|
|
var chain = new AuditChainVerificationResult("2025-10", AuditChainStatus.Intact, 9, 0, null, 9, 1, null);
|
|
_archive.VerifyAsync(Name, Arg.Any<CancellationToken>()).Returns(new AuditArchiveVerificationResult(
|
|
Name, "2025-10", AuditArchiveStatus.Purged, true, true, null, 10,
|
|
[new AuditArchiveChainVerification(chain, AuditArchiveAnchorStatus.Matches, "ends at the anchor")]));
|
|
|
|
CreateActor().Tell(Envelope(new VerifyAuditArchiveCommand(Name), [], "Viewer"));
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
using var json = JsonDocument.Parse(response.JsonData);
|
|
Assert.True(json.RootElement.GetProperty("verified").GetBoolean());
|
|
var c = json.RootElement.GetProperty("chains")[0];
|
|
Assert.Equal("Matches", c.GetProperty("anchor").GetString());
|
|
Assert.Equal("Intact", c.GetProperty("chain").GetProperty("status").GetString());
|
|
}
|
|
|
|
/// <summary>Designer/Deployer without an OperationalAudit role are refused both commands.</summary>
|
|
[Fact]
|
|
public void NonAuditRole_IsUnauthorized()
|
|
{
|
|
var actor = CreateActor();
|
|
actor.Tell(Envelope(new ListAuditArchivesCommand(), [], "Designer", "Deployer"));
|
|
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
actor.Tell(Envelope(new VerifyAuditArchiveCommand(Name), [], "Designer", "Deployer"));
|
|
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
_archive.DidNotReceiveWithAnyArgs().ListAsync(default);
|
|
_archive.DidNotReceiveWithAnyArgs().VerifyAsync(default!, default);
|
|
}
|
|
|
|
/// <summary>A site-scoped Viewer is refused: an archive holds every site's rows.</summary>
|
|
[Fact]
|
|
public void SiteScopedViewer_IsRefused()
|
|
{
|
|
CreateActor().Tell(Envelope(new VerifyAuditArchiveCommand(Name), ["3"], "Viewer"));
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("every site", response.Message);
|
|
_archive.DidNotReceiveWithAnyArgs().VerifyAsync(default!, default);
|
|
}
|
|
|
|
/// <summary>No archive directory configured is an explicit error code, not an empty list.</summary>
|
|
[Fact]
|
|
public void UnconfiguredArchive_IsArchiveNotConfigured()
|
|
{
|
|
_archive.IsConfigured.Returns(false);
|
|
|
|
CreateActor().Tell(Envelope(new ListAuditArchivesCommand(), [], "Administrator"));
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("ARCHIVE_NOT_CONFIGURED", response.ErrorCode);
|
|
}
|
|
|
|
/// <summary>A malformed name is INVALID_ARGUMENT; an unknown one is NOT_FOUND.</summary>
|
|
[Fact]
|
|
public void BadNames_MapToCallerErrors()
|
|
{
|
|
_archive.VerifyAsync("../etc", Arg.Any<CancellationToken>())
|
|
.Returns<Task<AuditArchiveVerificationResult>>(_ => throw new ArgumentException("not an archive name"));
|
|
_archive.VerifyAsync(Name, Arg.Any<CancellationToken>())
|
|
.Returns<Task<AuditArchiveVerificationResult>>(_ => throw new FileNotFoundException("no archive"));
|
|
|
|
var actor = CreateActor();
|
|
actor.Tell(Envelope(new VerifyAuditArchiveCommand("../etc"), [], "Administrator"));
|
|
Assert.Equal("INVALID_ARGUMENT", ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5)).ErrorCode);
|
|
actor.Tell(Envelope(new VerifyAuditArchiveCommand(Name), [], "Administrator"));
|
|
Assert.Equal("NOT_FOUND", ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5)).ErrorCode);
|
|
}
|
|
|
|
/// <summary>Archive verification runs under the long-running Ask timeout.</summary>
|
|
[Fact]
|
|
public void VerifyAuditArchive_UsesLongRunningAskTimeout()
|
|
{
|
|
Assert.Equal(TimeSpan.FromMinutes(5),
|
|
ManagementEndpoints.ResolveAskTimeout(new ManagementServiceOptions(), typeof(VerifyAuditArchiveCommand)));
|
|
}
|
|
}
|