Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogArchiveRetentionValidatorTests.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

83 lines
3.3 KiB
C#

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);
}
}
}