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:
Joseph Doherty
2026-11-09 23:32:53 -05:00
parent c4d7ec9b96
commit b95ee5a658
26 changed files with 1099 additions and 26 deletions
@@ -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);
}
}
@@ -30,13 +30,13 @@ public class AuditArchiveCommandTests
"\"rowsRead\":11,\"chains\":[{\"chain\":{\"partition\":\"2025-10\",\"status\":\"Intact\",\"rowsVerified\":10," +
"\"tombstonesApplied\":0,\"unchainedRows\":0},\"anchor\":\"Mismatch\",\"anchorDetail\":\"hash differs\"}]}";
/// <summary>The audit group carries the archive group with list + verify.</summary>
/// <summary>The audit group carries the archive group with list + verify + cleanup (ScadaBridge#109).</summary>
[Fact]
public void AuditGroup_HasArchiveListAndVerify()
public void AuditGroup_HasArchiveListVerifyAndCleanup()
{
var root = AuditCommandTestHarness.BuildRoot();
var archive = root.Subcommands.Single(c => c.Name == "audit").Subcommands.Single(c => c.Name == "archive");
Assert.Equal(["list", "verify"], archive.Subcommands.Select(c => c.Name).Order());
Assert.Equal(["cleanup", "list", "verify"], archive.Subcommands.Select(c => c.Name).Order());
}
/// <summary><c>list</c> sends ListAuditArchives and prints the archives.</summary>
@@ -78,6 +78,34 @@ public class AuditArchiveCommandTests
Assert.Equal(Name, name);
}
/// <summary>
/// <c>cleanup</c> (ScadaBridge#109) sends CleanupAuditArchives with the dry-run flag and
/// reports each action; it always exits 0.
/// </summary>
/// <param name="dryRun">Whether <c>--dry-run</c> is passed.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Cleanup_SendsCleanupAuditArchives_WithTheDryRunFlag(bool dryRun)
{
var body = "{\"dryRun\":" + (dryRun ? "true" : "false") + ",\"retentionDays\":0,\"items\":[{\"name\":\"" + Name
+ "\",\"partition\":\"2025-10\",\"action\":\"DeletedSuperseded\",\"detail\":\"superseded by x\"}]}";
string[] args = dryRun
? ["audit", "archive", "cleanup", "--dry-run", "--format", "table"]
: ["audit", "archive", "cleanup", "--format", "table"];
var (exit, output, request) = await RunAsync(body, args);
Assert.Equal(0, exit);
Assert.Contains("DeletedSuperseded", output);
Assert.Contains("never delete", output);
Assert.Equal(dryRun, output.Contains("Dry run", StringComparison.Ordinal));
using var payload = JsonDocument.Parse(request);
Assert.Equal("CleanupAuditArchives", payload.RootElement.GetProperty("command").GetString());
Assert.Equal(dryRun, payload.RootElement.GetProperty("payload").EnumerateObject()
.First(p => string.Equals(p.Name, "dryRun", StringComparison.OrdinalIgnoreCase)).Value.GetBoolean());
}
/// <summary><c>verify</c> without <c>--name</c> is a usage error.</summary>
[Fact]
public void Verify_MissingName_ProducesRequiredFlagError()
@@ -62,10 +62,10 @@ public class AuditCommandsScaffoldTests
[Fact]
public void Audit_EveryLeafCommand_HasAnAction()
{
// `archive` is a group (list / verify, ScadaBridge#42): check its leaves, not it.
// `archive` is a group (list / verify, ScadaBridge#42; cleanup, ScadaBridge#109): check its leaves, not it.
var audit = BuildAudit();
var leaves = audit.Subcommands.SelectMany(c => c.Subcommands.Count == 0 ? [c] : c.Subcommands).ToList();
Assert.Equal(8, leaves.Count); // + verify-heads (ScadaBridge#107)
Assert.Equal(9, leaves.Count); // + verify-heads (ScadaBridge#107), archive cleanup (ScadaBridge#109)
Assert.All(leaves, sub =>
Assert.True(sub.Action != null, $"Leaf command '{sub.Name}' has no action."));
}
@@ -45,7 +45,7 @@ public class ManagementSurfaceParityTests
}
private static readonly HashSet<string> AuditManagementLeaves =
["audit verify-chain", "audit verify-heads", "audit archive list", "audit archive verify"];
["audit verify-chain", "audit verify-heads", "audit archive list", "audit archive verify", "audit archive cleanup"];
private static async Task<ConcurrentDictionary<string, string>> ProbeProductionTree(string? omitGroup = null)
{
@@ -0,0 +1,296 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance;
/// <summary>
/// SQL Server tests for AuditLog archive cleanup (ScadaBridge#109): a stale archive a retried
/// purge left behind is deleted only once a later purged archive provably holds all its rows;
/// a stale archive with a row no later archive holds, or a sole copy left at <c>Archived</c>, is
/// kept; retention deletes only expired archives whose partition is gone; deletion never
/// follows a path out of the archive folder.
/// </summary>
/// <remarks>Each test owns one month of the shared fixture database and its own archive directory.</remarks>
public sealed class AuditLogArchiveCleanupTests : IClassFixture<MsSqlMigrationFixture>, IDisposable
{
private readonly MsSqlMigrationFixture _fixture;
private readonly DirectoryInfo _root = Directory.CreateTempSubdirectory("auditlog-cleanup-");
/// <summary>Creates the test class with the shared migrated SQL Server fixture.</summary>
/// <param name="fixture">The shared migration fixture.</param>
public AuditLogArchiveCleanupTests(MsSqlMigrationFixture fixture) => _fixture = fixture;
/// <inheritdoc />
public void Dispose() => _root.Delete(recursive: true);
/// <summary>
/// The retried purge: archive A goes stale (a row arrives and the chain advances), B purges.
/// Dry run reports A as superseded and deletes nothing; the real pass deletes exactly A's
/// three files and B still verifies.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task RetriedPurge_StaleArchiveIsDeleted_OnceALaterPurgedArchiveCoversIt()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var dir = Dir("retried");
var month = Month(2027, 1);
await InsertAsync(Enumerable.Range(1, 5).Select(i => NewEvent(month.AddDays(i))));
await DrainAsync();
await InsertAsync([NewEvent(month.AddDays(7))]); // unchained in A
var stale = await ArchiveAsync(dir, month);
Assert.Equal(1, stale.UnchainedRowCount);
await InsertAsync([NewEvent(month.AddDays(9))]);
await DrainAsync(); // A's unchained row is now chained; the chain moved past A's end
await using (var ctx = CreateContext())
{
await Assert.ThrowsAsync<AuditArchiveStaleException>(() => Archive(ctx, dir).PurgeArchivedPartitionAsync(stale));
}
var fresh = await ArchiveAsync(dir, month);
await using (var ctx = CreateContext())
{
Assert.Equal(7, await Archive(ctx, dir).PurgeArchivedPartitionAsync(fresh));
}
var dry = await CleanupAsync(dir, dryRun: true);
var item = Assert.Single(dry.Items);
Assert.Equal((stale.Name, AuditArchiveCleanupAction.DeletedSuperseded), (item.Name, item.Action));
Assert.True(File.Exists(ManifestPath(dir, stale)));
var real = await CleanupAsync(dir, dryRun: false);
Assert.Equal(AuditArchiveCleanupAction.DeletedSuperseded, Assert.Single(real.Items).Action);
Assert.False(File.Exists(ManifestPath(dir, stale)));
Assert.False(File.Exists(Path.Combine(dir, "2027", stale.Name + ".parquet")));
Assert.False(File.Exists(Path.Combine(dir, "2027", stale.Name + ".tombstones.parquet")));
await using (var ctx = CreateContext())
{
var listed = Assert.Single(await Archive(ctx, dir).ListAsync());
Assert.Equal(fresh.Name, listed.Name);
Assert.True((await Archive(ctx, dir).VerifyAsync(fresh.Name)).Verified);
}
Assert.Empty((await CleanupAsync(dir, dryRun: false)).Items);
}
/// <summary>
/// A stale archive holding an unchained row that a per-channel purge removed before the
/// later archive (no tombstone for an unchained row) is the only copy of that row: kept.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task StaleArchive_WithARowNoLaterArchiveHolds_IsKept()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var dir = Dir("kept");
var month = Month(2027, 3);
await InsertAsync(Enumerable.Range(1, 3).Select(i => NewEvent(month.AddDays(i))));
await DrainAsync();
await InsertAsync([NewEvent(month.AddHours(1), AuditChannel.DbOutbound)]); // unchained
var stale = await ArchiveAsync(dir, month);
await using (var ctx = CreateContext())
{
Assert.Equal(1, await new AuditLogRepository(ctx).PurgeChannelOlderThanAsync("DbOutbound", month.AddDays(1), 100));
await Assert.ThrowsAsync<AuditArchiveStaleException>(() => Archive(ctx, dir).PurgeArchivedPartitionAsync(stale));
}
var fresh = await ArchiveAsync(dir, month);
await using (var ctx = CreateContext())
{
await Archive(ctx, dir).PurgeArchivedPartitionAsync(fresh);
}
var result = await CleanupAsync(dir, dryRun: false);
var kept = Assert.Single(result.Items);
Assert.Equal((stale.Name, AuditArchiveCleanupAction.KeptStale), (kept.Name, kept.Action));
Assert.Contains("unchained row", kept.Detail);
Assert.True(File.Exists(ManifestPath(dir, stale)));
}
/// <summary>
/// An archive left at <c>Archived</c> although its partition WAS switched out (the node
/// stopped before the manifest update) has no later purged archive: it is the sole copy, kept.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SoleCopyLeftAtArchived_IsNotTouched()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var dir = Dir("sole");
var month = Month(2027, 4);
await InsertAsync(Enumerable.Range(1, 3).Select(i => NewEvent(month.AddDays(i))));
await DrainAsync();
var manifest = await ArchiveAsync(dir, month);
await using (var ctx = CreateContext())
{
await Archive(ctx, dir).PurgeArchivedPartitionAsync(manifest);
}
await SetStatusAsync(dir, manifest, AuditArchiveStatus.Archived);
Assert.Empty((await CleanupAsync(dir, dryRun: false)).Items);
Assert.True(File.Exists(ManifestPath(dir, manifest)));
}
/// <summary>
/// Retention: 0 never deletes; a purged archive is kept until its month ended more than
/// RetentionDays ago, then deleted; an expired archive whose partition still holds rows is kept.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Retention_DeletesOnlyExpiredArchivesWhosePartitionIsGone()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var dir = Dir("retention");
var purgedMonth = Month(2027, 5);
await InsertAsync(Enumerable.Range(1, 2).Select(i => NewEvent(purgedMonth.AddDays(i))));
var purged = await ArchiveAsync(dir, purgedMonth);
await using (var ctx = CreateContext())
{
await Archive(ctx, dir).PurgeArchivedPartitionAsync(purged);
}
var liveMonth = Month(2027, 6);
await InsertAsync([NewEvent(liveMonth.AddDays(1))]);
var live = await ArchiveAsync(dir, liveMonth); // never purged: rows still in the table
var monthEnd = purgedMonth.AddMonths(1);
var farFuture = liveMonth.AddMonths(1).AddDays(1000);
Assert.Empty((await CleanupAsync(dir, dryRun: false, retentionDays: 0, now: farFuture)).Items);
Assert.Empty((await CleanupAsync(dir, dryRun: false, retentionDays: 400, now: monthEnd.AddDays(399))).Items);
var result = await CleanupAsync(dir, dryRun: false, retentionDays: 400, now: farFuture);
var expired = Assert.Single(result.Items);
Assert.Equal((purged.Name, AuditArchiveCleanupAction.DeletedExpired), (expired.Name, expired.Action));
Assert.False(File.Exists(ManifestPath(dir, purged)));
Assert.True(File.Exists(ManifestPath(dir, live)));
}
/// <summary>Deletion uses the archive name's own three files — never a path from the manifest.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Deletion_NeverFollowsAManifestPath_OutOfTheArchiveFolder()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var dir = Dir("paths");
var month = Month(2027, 7);
await InsertAsync([NewEvent(month.AddDays(1))]);
var manifest = await ArchiveAsync(dir, month);
await using (var ctx = CreateContext())
{
await Archive(ctx, dir).PurgeArchivedPartitionAsync(manifest);
}
var outside = Path.Combine(_root.FullName, "outside.parquet");
await File.WriteAllTextAsync(outside, "not an archive");
var path = ManifestPath(dir, manifest);
var tampered = JsonSerializer.Deserialize<AuditArchiveManifest>(await File.ReadAllTextAsync(path), Json)! with
{
DataFile = "../../outside.parquet",
TombstoneFile = "../../outside.parquet",
};
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(tampered, Json));
var result = await CleanupAsync(dir, dryRun: false, retentionDays: 30, now: month.AddYears(5));
Assert.Equal(AuditArchiveCleanupAction.DeletedExpired, Assert.Single(result.Items).Action);
Assert.True(File.Exists(outside));
Assert.Empty(Directory.EnumerateFiles(Path.Combine(dir, "2027")));
}
private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private sealed class FixedClock(DateTime now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(DateTime.SpecifyKind(now, DateTimeKind.Utc));
}
private string Dir(string name) => Directory.CreateDirectory(Path.Combine(_root.FullName, name)).FullName;
private static string ManifestPath(string dir, AuditArchiveManifest m) => Path.Combine(dir, m.Partition[..4], m.Name + ".manifest.json");
private static async Task SetStatusAsync(string dir, AuditArchiveManifest manifest, AuditArchiveStatus status)
{
var path = ManifestPath(dir, manifest);
var current = JsonSerializer.Deserialize<AuditArchiveManifest>(await File.ReadAllTextAsync(path), Json)!;
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(current with { Status = status, PurgedAtUtc = null }, Json));
}
private static AuditLogPartitionArchive Archive(
ScadaBridgeDbContext ctx, string dir, int retentionDays = 0, DateTime? now = null) => new(ctx, new AuditLogArchiveOptions
{
Enabled = true,
Directory = dir,
RowGroupSize = 1_000,
RetentionDays = retentionDays,
}, time: now is null ? null : new FixedClock(now.Value));
private async Task<AuditArchiveManifest> ArchiveAsync(string dir, DateTime month)
{
await using var ctx = CreateContext();
var manifest = await Archive(ctx, dir).ArchivePartitionAsync(month);
await Task.Delay(5); // distinct millisecond archive names
return manifest;
}
private async Task<AuditArchiveCleanupResult> CleanupAsync(string dir, bool dryRun, int retentionDays = 0, DateTime? now = null)
{
await using var ctx = CreateContext();
return await Archive(ctx, dir, retentionDays, now).CleanupAsync(dryRun);
}
private async Task InsertAsync(IEnumerable<AuditEvent> events)
{
await using var ctx = CreateContext();
var repo = new AuditLogRepository(ctx);
foreach (var e in events)
{
await repo.InsertIfNotExistsAsync(e);
}
}
private async Task DrainAsync()
{
await using var ctx = CreateContext();
var chain = new AuditLogHashChain(ctx);
for (var i = 0; i < 1000; i++)
{
var r = await chain.ChainPendingAsync(500);
if (r.RowsChained == 0 && !r.LockBusy)
{
return;
}
}
throw new InvalidOperationException("Chain did not drain.");
}
private static DateTime Month(int year, int month) => new(year, month, 1, 0, 0, 0, DateTimeKind.Utc);
private ScadaBridgeDbContext CreateContext()
=> new(new DbContextOptionsBuilder<ScadaBridgeDbContext>().UseSqlServer(_fixture.ConnectionString).Options);
private static AuditEvent NewEvent(DateTime occurredAtUtc, AuditChannel channel = AuditChannel.ApiOutbound) =>
ScadaBridgeAuditEventFactory.Create(
channel: channel,
kind: channel == AuditChannel.DbOutbound ? AuditKind.DbWrite : AuditKind.ApiCall,
status: AuditStatus.Delivered,
occurredAtUtc: occurredAtUtc,
actor: "cleanup-test",
target: "erp",
sourceNode: "central-a",
sourceSiteId: "cleanup-test",
correlationId: Guid.NewGuid());
}
@@ -144,4 +144,43 @@ public class AuditArchiveCommandTests : TestKit
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);
}
}
@@ -176,6 +176,8 @@ public class RequiredRoleMatrixTests
// AuditLog Parquet archives (ScadaBridge#42): same audit-read gate as verify-chain.
["ListAuditArchives"] = [Roles.Administrator, Roles.Viewer],
["VerifyAuditArchive"] = [Roles.Administrator, Roles.Viewer],
// Archive cleanup deletes files (ScadaBridge#109): Administrator only.
["CleanupAuditArchives"] = [Roles.Administrator],
// ---- Two-person secured write ----------------------------------------------
["SubmitSecuredWrite"] = [Roles.Operator],