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; /// /// 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 Archived, is /// kept; retention deletes only expired archives whose partition is gone; deletion never /// follows a path out of the archive folder. /// /// Each test owns one month of the shared fixture database and its own archive directory. public sealed class AuditLogArchiveCleanupTests : IClassFixture, IDisposable { private readonly MsSqlMigrationFixture _fixture; private readonly DirectoryInfo _root = Directory.CreateTempSubdirectory("auditlog-cleanup-"); /// Creates the test class with the shared migrated SQL Server fixture. /// The shared migration fixture. public AuditLogArchiveCleanupTests(MsSqlMigrationFixture fixture) => _fixture = fixture; /// public void Dispose() => _root.Delete(recursive: true); /// /// 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. /// /// A task that represents the asynchronous operation. [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(() => 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); } /// /// 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. /// /// A task that represents the asynchronous operation. [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(() => 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))); } /// /// An archive left at Archived although its partition WAS switched out (the node /// stopped before the manifest update) has no later purged archive: it is the sole copy, kept. /// /// A task that represents the asynchronous operation. [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))); } /// /// 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. /// /// A task that represents the asynchronous operation. [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))); } /// Deletion uses the archive name's own three files — never a path from the manifest. /// A task that represents the asynchronous operation. [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(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(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 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 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 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().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()); }