Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditArchiveCommandTests.cs
T
Joseph Doherty b95ee5a658 feat(audit): archive retention and superseded stale-archive cleanup (ScadaBridge#109)
Owner decisions recorded: the archive destination stays a directory/UNC
(no object-storage client; share + permissions named at enablement),
parquet-tools-only reading (no re-import), unsigned like the chain (#107).

AuditLog:Archive:RetentionDays (default 0 = never delete; else must exceed
AuditLog:RetentionDays, counted from the archived month's end) and
DeleteSupersededStaleArchives (default true) run after every purge tick.
A retried purge's stale Archived archive is deleted only when a later,
verifying, Purged archive of the same partition provably holds every row:
same prior anchor and the stale chain-end hash at its chain-end sequence
(row or tombstone), every unchained row present by EventId; otherwise it is
reported KeptStale. A sole copy left at Archived is never touched. Deletion
removes only the archive name's own three files in its folder, manifest
first, never a manifest-supplied path. `audit archive cleanup [--dry-run]`
= CleanupAuditArchives (Administrator).

AuditLogArchiveCleanupTests (MSSQL) cover retried purge, kept stale copy,
sole copy, retention and the path guard; each safety rule mutation-checked.
Follow-ups filed: #113, #114, #115.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 23:33:04 -05:00

187 lines
8.7 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)));
}
/// <summary>ScadaBridge#109: an Administrator runs the cleanup; the dry-run flag reaches the archive.</summary>
/// <param name="dryRun">The flag.</param>
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Administrator_RunsCleanup_WithTheDryRunFlag(bool dryRun)
{
_archive.CleanupAsync(dryRun, Arg.Any<CancellationToken>()).Returns(new AuditArchiveCleanupResult(dryRun, 0,
[new AuditArchiveCleanupItem(Name, "2025-10", AuditArchiveCleanupAction.DeletedSuperseded, "superseded")]));
CreateActor().Tell(Envelope(new CleanupAuditArchivesCommand(dryRun), [], "Administrator"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
using var json = JsonDocument.Parse(response.JsonData);
Assert.Equal(dryRun, json.RootElement.GetProperty("dryRun").GetBoolean());
Assert.Equal("DeletedSuperseded", json.RootElement.GetProperty("items")[0].GetProperty("action").GetString());
_archive.Received(1).CleanupAsync(dryRun, Arg.Any<CancellationToken>());
}
/// <summary>Cleanup deletes files: a Viewer (audit reader) is refused.</summary>
[Fact]
public void Viewer_CannotRunCleanup()
{
CreateActor().Tell(Envelope(new CleanupAuditArchivesCommand(true), [], "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
_archive.DidNotReceiveWithAnyArgs().CleanupAsync(default, default);
}
/// <summary>No archive directory configured: ARCHIVE_NOT_CONFIGURED, as for list/verify.</summary>
[Fact]
public void Cleanup_WithoutAnArchiveDirectory_IsNotConfigured()
{
_archive.IsConfigured.Returns(false);
CreateActor().Tell(Envelope(new CleanupAuditArchivesCommand(true), [], "Administrator"));
Assert.Equal("ARCHIVE_NOT_CONFIGURED", ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5)).ErrorCode);
}
}