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
This commit is contained in:
+82
@@ -0,0 +1,82 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
||||
|
||||
/// <summary>
|
||||
/// <c>AuditLog:Archive:RetentionDays</c> (ScadaBridge#109): 0 = never delete; otherwise in range
|
||||
/// and greater than the global <c>AuditLog:RetentionDays</c>.
|
||||
/// </summary>
|
||||
public sealed class AuditLogArchiveRetentionValidatorTests
|
||||
{
|
||||
/// <summary>Range check in the section validator.</summary>
|
||||
/// <param name="days">RetentionDays.</param>
|
||||
/// <param name="valid">Expected outcome.</param>
|
||||
[Theory]
|
||||
[InlineData(0, true)]
|
||||
[InlineData(400, true)]
|
||||
[InlineData(36_500, true)]
|
||||
[InlineData(-1, false)]
|
||||
[InlineData(36_501, false)]
|
||||
public void SectionValidator_ChecksTheRange(int days, bool valid)
|
||||
{
|
||||
var result = new AuditLogArchiveOptionsValidator().Validate(null, new AuditLogArchiveOptions { RetentionDays = days });
|
||||
Assert.Equal(valid, result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Cross-section check against the global retention (365 here).</summary>
|
||||
/// <param name="days">RetentionDays.</param>
|
||||
/// <param name="valid">Expected outcome.</param>
|
||||
[Theory]
|
||||
[InlineData(0, true)]
|
||||
[InlineData(366, true)]
|
||||
[InlineData(365, false)]
|
||||
[InlineData(30, false)]
|
||||
public void RetentionValidator_RequiresMoreThanTheGlobalRetention(int days, bool valid)
|
||||
{
|
||||
var validator = new AuditLogArchiveRetentionValidator(Options.Create(new AuditLogOptions { RetentionDays = 365 }));
|
||||
var result = validator.Validate(null, new AuditLogArchiveOptions { RetentionDays = days });
|
||||
Assert.Equal(valid, result.Succeeded);
|
||||
if (!valid)
|
||||
{
|
||||
Assert.Contains("AuditLog:RetentionDays (365)", result.FailureMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The cross-section check is wired into the central registration.</summary>
|
||||
/// <param name="days">Archive RetentionDays.</param>
|
||||
/// <param name="valid">Whether binding succeeds.</param>
|
||||
[Theory]
|
||||
[InlineData("0", true)]
|
||||
[InlineData("400", true)]
|
||||
[InlineData("100", false)]
|
||||
public void CentralRegistration_RejectsAnArchiveRetentionBelowTheGlobalOne(string days, bool valid)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
|
||||
["AuditLog:RetentionDays"] = "365",
|
||||
["AuditLog:Archive:RetentionDays"] = days,
|
||||
})
|
||||
.Build();
|
||||
var services = new ServiceCollection();
|
||||
services.AddAuditLog(config);
|
||||
services.AddAuditLogCentralMaintenance(config);
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var resolve = () => provider.GetRequiredService<IOptions<AuditLogArchiveOptions>>().Value;
|
||||
if (valid)
|
||||
{
|
||||
Assert.Equal(int.Parse(days), resolve().RetentionDays);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Contains("AuditLog:Archive:RetentionDays (100)", Assert.Throws<OptionsValidationException>(resolve).Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -822,4 +822,65 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
|
||||
StopPurgeActor(actor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#109: with archiving enabled every tick ends with the archive cleanup pass —
|
||||
/// also on a tick with no expired partition — and never runs it when archiving is off.
|
||||
/// </summary>
|
||||
/// <param name="withBoundary">Whether the tick has an expired partition to purge.</param>
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void ArchiveEnabled_RunsTheCleanupPass_EveryTick(bool withBoundary)
|
||||
{
|
||||
var boundary = new DateTime(2025, 10, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var repo = new RecordingRepo { Boundaries = withBoundary ? [boundary] : [] };
|
||||
var archive = Substitute.For<IAuditLogArchive>();
|
||||
var manifest = ManifestFor(boundary);
|
||||
archive.ArchivePartitionAsync(boundary, Arg.Any<CancellationToken>()).Returns(manifest);
|
||||
archive.CleanupAsync(false, Arg.Any<CancellationToken>()).Returns(new AuditArchiveCleanupResult(false, 0,
|
||||
[new AuditArchiveCleanupItem("auditlog-2025-10-20261109T120000000Z", "2025-10", AuditArchiveCleanupAction.DeletedSuperseded, "x")]));
|
||||
|
||||
var actor = CreateArchivingActor(repo, archive, enabled: true);
|
||||
actor.Tell(AuditLogPurgeActor.PurgeTick.Instance);
|
||||
|
||||
AwaitAssert(() => archive.Received().CleanupAsync(false, Arg.Any<CancellationToken>()),
|
||||
duration: TimeSpan.FromSeconds(5), interval: TimeSpan.FromMilliseconds(50));
|
||||
StopPurgeActor(actor);
|
||||
}
|
||||
|
||||
/// <summary>A cleanup failure is contained and surfaces as a purge failure.</summary>
|
||||
[Fact]
|
||||
public void ArchiveCleanupFailure_IsAPurgeFailure()
|
||||
{
|
||||
var repo = new RecordingRepo();
|
||||
var archive = Substitute.For<IAuditLogArchive>();
|
||||
archive.CleanupAsync(false, Arg.Any<CancellationToken>())
|
||||
.Returns<Task<AuditArchiveCleanupResult>>(_ => throw new IOException("share offline"));
|
||||
var failed = CreateTestProbe();
|
||||
Sys.EventStream.Subscribe(failed.Ref, typeof(AuditLogPurgeFailedEvent));
|
||||
|
||||
var actor = CreateArchivingActor(repo, archive, enabled: true);
|
||||
actor.Tell(AuditLogPurgeActor.PurgeTick.Instance);
|
||||
|
||||
var evt = failed.ExpectMsg<AuditLogPurgeFailedEvent>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("share offline", evt.Error);
|
||||
Assert.Equal(DateTime.MinValue, evt.MonthBoundary);
|
||||
StopPurgeActor(actor);
|
||||
}
|
||||
|
||||
/// <summary>Archiving off: no cleanup pass.</summary>
|
||||
[Fact]
|
||||
public void ArchiveDisabled_RunsNoCleanup()
|
||||
{
|
||||
var repo = new RecordingRepo();
|
||||
var archive = Substitute.For<IAuditLogArchive>();
|
||||
var actor = CreateArchivingActor(repo, archive, enabled: false);
|
||||
actor.Tell(AuditLogPurgeActor.PurgeTick.Instance);
|
||||
|
||||
AwaitAssert(() => Assert.NotEmpty(repo.ThresholdQueries),
|
||||
duration: TimeSpan.FromSeconds(5), interval: TimeSpan.FromMilliseconds(50));
|
||||
archive.DidNotReceiveWithAnyArgs().CleanupAsync(default, default);
|
||||
StopPurgeActor(actor);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user