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

887 lines
41 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
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;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Tests for <see cref="AuditLogPurgeActor"/>. The fast,
/// schedule-only tests substitute a recording stub for
/// <see cref="IAuditLogRepository"/> so the timer + per-boundary error-isolation
/// + event-publish machinery can be exercised without an MSSQL container.
/// The end-to-end "real partition gets switched out" assertion lives in the
/// repository tests; this actor file is purely about the
/// actor's policy decisions.
/// </summary>
public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
/// <param name="fixture">The shared MSSQL migration fixture.</param>
public AuditLogPurgeActorTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
/// <summary>
/// In-memory recording stub. Captures every
/// <see cref="GetPartitionBoundariesOlderThanAsync"/> + every
/// <see cref="SwitchOutPartitionAsync"/> so tests can assert which boundaries
/// the actor chose to purge and how many ticks it issued. Also lets a
/// specific boundary be configured to throw so the continue-on-error path
/// is exercisable.
/// </summary>
private sealed class RecordingRepo : IAuditLogRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
/// <summary>Gets the thresholds passed to each partition-boundary query.</summary>
public List<DateTime> ThresholdQueries { get; } = new();
/// <summary>Gets the partition boundaries that were switched out, in call order.</summary>
public List<DateTime> SwitchedBoundaries { get; } = new();
/// <summary>Gets or sets the function that returns the row count reported for a switched boundary.</summary>
public Func<DateTime, long> RowsPerBoundary { get; set; } = _ => 0L;
/// <summary>Gets or sets the boundary whose switch-out should throw, if any.</summary>
public DateTime? ThrowOnBoundary { get; set; }
/// <summary>Gets or sets the exception thrown for <see cref="ThrowOnBoundary"/>; a default failure is used when null.</summary>
public Exception? BoundaryException { get; set; }
// Captures the maintenance command timeout the actor threads through to the
// switch-out + per-channel purge, so tests can assert the options value reaches the repo.
/// <summary>Gets the command timeouts passed to each partition switch-out call.</summary>
public List<TimeSpan?> SwitchTimeouts { get; } = new();
/// <summary>Gets the command timeouts passed to each per-channel purge call.</summary>
public List<TimeSpan?> ChannelTimeouts { get; } = new();
// Records every per-channel purge call as
// (channel, threshold, batchSize) so tests can assert which channels the
// actor chose to purge and with what window.
/// <summary>Gets every per-channel purge call as a channel, threshold, batch size and row ceiling.</summary>
public List<(string Channel, DateTime Threshold, int BatchSize, int MaxRows)> ChannelPurges { get; } = new();
/// <summary>Gets or sets the function that returns the row count reported for a purged channel.</summary>
public Func<string, long> RowsPerChannel { get; set; } = _ => 0L;
// The actor enumerator returns whichever list is configured here.
// Mutating this between ticks lets tests simulate "no longer
// eligible" boundaries on the second tick.
/// <summary>Gets or sets the partition boundaries returned by the next threshold query.</summary>
public List<DateTime> Boundaries { get; set; } = new();
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default) =>
Task.CompletedTask;
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>());
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
{
SwitchTimeouts.Add(commandTimeout);
if (ThrowOnBoundary.HasValue && monthBoundary == ThrowOnBoundary.Value)
{
throw BoundaryException ?? new InvalidOperationException("simulated switch failure");
}
SwitchedBoundaries.Add(monthBoundary);
return Task.FromResult(RowsPerBoundary(monthBoundary));
}
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default)
{
ThresholdQueries.Add(threshold);
return Task.FromResult<IReadOnlyList<DateTime>>(Boundaries.ToArray());
}
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000)
{
ChannelPurges.Add((channel, threshold, batchSize, maxRows));
ChannelTimeouts.Add(commandTimeout);
return Task.FromResult(RowsPerChannel(channel));
}
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
Task.FromResult(0L);
/// <inheritdoc />
public Task<ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)
{
var services = new ServiceCollection();
// Mirror AddConfigurationDatabase: IAuditLogRepository is scoped, so
// the actor opens a fresh scope per tick and resolves there.
services.AddScoped(_ => repo);
return services.BuildServiceProvider();
}
private IActorRef CreateActor(
IAuditLogRepository repo,
AuditLogPurgeOptions purgeOptions,
AuditLogOptions? auditOptions = null)
{
var sp = BuildScopedProvider(repo);
return Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
sp,
Options.Create(purgeOptions),
Options.Create(auditOptions ?? new AuditLogOptions()),
NullLogger<AuditLogPurgeActor>.Instance)));
}
/// <summary>
/// Stops a purge actor and blocks until it is actually dead.
/// </summary>
/// <remarks>
/// <para>
/// Deferred-work register row 95, the same leak shape row 82 fixed in
/// <c>PartitionPurgeTests</c> / <c>NotifyDispatcherAuditTrailTests</c>. Every actor this
/// class creates ticks every <c>100 ms</c>
/// (<see cref="AuditLogPurgeOptions.IntervalOverride"/>) and was only ever stopped
/// implicitly by <see cref="TestKit"/>'s <c>Dispose</c> → <c>Shutdown</c> — which is
/// bounded by a timeout and does NOT fail when that timeout expires. So on a loaded
/// machine an actor kept ticking while LATER test classes ran: the stub-repo tests burn a
/// DI scope and a thread every 100 ms for the rest of the assembly run, and
/// <c>EndToEnd_RealPartition_…</c>'s actor keeps switching partitions out of the shared
/// <see cref="MsSqlMigrationFixture"/> database that other classes in this assembly seed
/// against.
/// </para>
/// <para>
/// Stopping the actor inside the test that created it removes the dependency on
/// ActorSystem-shutdown timing entirely.
/// </para>
/// </remarks>
private void StopPurgeActor(IActorRef actor)
{
Watch(actor);
Sys.Stop(actor);
// A stop is processed after the in-flight tick completes, so this also guarantees no
// partition switch is still running against the fixture DB.
ExpectTerminated(actor, TimeSpan.FromSeconds(30));
Unwatch(actor);
}
private static AuditLogPurgeOptions FastTickOptions(TimeSpan? interval = null) => new()
{
IntervalHours = 24,
IntervalOverride = interval ?? TimeSpan.FromMilliseconds(100),
};
/// <summary>
/// Subscribe a probe to the EventStream so the test can observe
/// <see cref="AuditLogPurgedEvent"/> publications synchronously.
/// </summary>
private Akka.TestKit.TestProbe SubscribePurged()
{
var probe = CreateTestProbe();
Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgedEvent));
return probe;
}
// ---------------------------------------------------------------------
// 1. Tick_Fires_OnDailyInterval
// ---------------------------------------------------------------------
/// <summary>Verifies that the purge actor runs a purge pass on each daily interval tick.</summary>
[Fact]
public void Tick_Fires_OnDailyInterval()
{
var repo = new RecordingRepo();
var actor = CreateActor(repo, FastTickOptions());
// The first scheduled tick fires after the configured interval. We
// assert the visible side effect (the enumerator was called) rather
// than racing on internal state.
AwaitAssert(
() => Assert.True(repo.ThresholdQueries.Count >= 1,
$"expected >= 1 enumerator call, got {repo.ThresholdQueries.Count}"),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
StopPurgeActor(actor);
}
// ---------------------------------------------------------------------
// 2. Tick_OldPartitions_SwitchedOut
// ---------------------------------------------------------------------
/// <summary>Verifies that a tick switches out partitions older than the retention threshold.</summary>
[Fact]
public void Tick_OldPartitions_SwitchedOut()
{
var repo = new RecordingRepo
{
Boundaries = new List<DateTime>
{
new(2025, 11, 1, 0, 0, 0, DateTimeKind.Utc),
new(2025, 12, 1, 0, 0, 0, DateTimeKind.Utc),
},
RowsPerBoundary = _ => 42L,
};
var actor = CreateActor(repo, FastTickOptions());
AwaitAssert(
() =>
{
Assert.Contains(new DateTime(2025, 11, 1, 0, 0, 0, DateTimeKind.Utc), repo.SwitchedBoundaries);
Assert.Contains(new DateTime(2025, 12, 1, 0, 0, 0, DateTimeKind.Utc), repo.SwitchedBoundaries);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
StopPurgeActor(actor);
}
// ---------------------------------------------------------------------
// 3. Tick_NewerPartitions_Untouched
// ---------------------------------------------------------------------
/// <summary>Verifies that a tick leaves partitions newer than the retention threshold untouched.</summary>
[Fact]
public void Tick_NewerPartitions_Untouched()
{
// The actor's contract: it only touches whatever the enumerator
// returns. The enumerator (in production) filters out non-eligible
// boundaries; here we simulate that by handing back an empty list
// and asserting the actor switched nothing despite the tick firing.
var repo = new RecordingRepo { Boundaries = new List<DateTime>() };
var actor = CreateActor(repo, FastTickOptions());
// Wait for at least one tick (visible via the enumerator call) then
// assert no switch happened.
AwaitAssert(
() => Assert.True(repo.ThresholdQueries.Count >= 1),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
// Stop before the negative assertion: a still-ticking actor is exactly what would
// make it read a moving target (register row 95).
StopPurgeActor(actor);
Assert.Empty(repo.SwitchedBoundaries);
}
// ---------------------------------------------------------------------
// 4. Tick_PublishesPurgedEvent_WithRowCount
// ---------------------------------------------------------------------
/// <summary>Verifies that a tick publishes a purged event carrying the number of rows removed.</summary>
[Fact]
public void Tick_PublishesPurgedEvent_WithRowCount()
{
var boundary = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc);
var repo = new RecordingRepo
{
Boundaries = new List<DateTime> { boundary },
RowsPerBoundary = _ => 1234L,
};
var probe = SubscribePurged();
var actor = CreateActor(repo, FastTickOptions());
var msg = probe.ExpectMsg<AuditLogPurgedEvent>(TimeSpan.FromSeconds(5));
StopPurgeActor(actor);
Assert.Equal(boundary, msg.MonthBoundary);
Assert.Equal(1234L, msg.RowsDeleted);
Assert.True(msg.DurationMs >= 0,
$"DurationMs should be non-negative; was {msg.DurationMs}");
}
// ---------------------------------------------------------------------
// 5. Tick_SwitchThrows_OtherPartitionsStillProcessed (continue-on-error)
// ---------------------------------------------------------------------
/// <summary>Verifies that a failed switch-out does not prevent the remaining partitions from being processed.</summary>
[Fact]
public void Tick_SwitchThrows_OtherPartitionsStillProcessed()
{
var poisonBoundary = new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc);
var goodBoundary = new DateTime(2025, 8, 1, 0, 0, 0, DateTimeKind.Utc);
var repo = new RecordingRepo
{
Boundaries = new List<DateTime> { poisonBoundary, goodBoundary },
ThrowOnBoundary = poisonBoundary,
BoundaryException = new InvalidOperationException("simulated switch failure for poison boundary"),
};
var actor = CreateActor(repo, FastTickOptions());
AwaitAssert(
() =>
{
// The good boundary was still switched even though the poison
// boundary threw.
Assert.Contains(goodBoundary, repo.SwitchedBoundaries);
Assert.DoesNotContain(poisonBoundary, repo.SwitchedBoundaries);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
StopPurgeActor(actor);
}
// ---------------------------------------------------------------------
// 6. EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished
// ---------------------------------------------------------------------
/// <summary>Verifies that a real partition is switched out end to end, its rows are removed and a purged event is published.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task EndToEnd_RealPartition_RowsRemoved_PurgedEventPublished()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Seeds two rows within the defined pf_AuditLog_Month partition range (Jan 2026 –
// Dec 2027). RetentionDays is computed dynamically so the purge threshold always
// anchors near 2026-01-20, keeping the test date-independent:
// old row = Jan 15 2026 → Jan 15 < threshold ~Jan 20 → partition PURGED
// kept row = Apr 15 2026 → Apr 15 > threshold ~Jan 20 → partition KEPT
//
// Using a fixed thresholdAnchor rather than "N months ago" avoids the problem
// of relative seeds landing before 2026-01-01 (the catch-all partition that
// GetPartitionBoundariesOlderThanAsync never returns).
var thresholdAnchor = new DateTime(2026, 1, 20, 0, 0, 0, DateTimeKind.Utc);
var retentionDays = (int)(DateTime.UtcNow - thresholdAnchor).TotalDays + 1;
var oldOccurred = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc);
var keptOccurred = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc);
var siteId = "purge-e2e-" + Guid.NewGuid().ToString("N").Substring(0, 8);
var oldEvt = ScadaBridgeAuditEventFactory.Create(
eventId: Guid.NewGuid(),
occurredAtUtc: oldOccurred,
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
sourceSiteId: siteId);
var keptEvt = ScadaBridgeAuditEventFactory.Create(
eventId: Guid.NewGuid(),
occurredAtUtc: keptOccurred,
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
sourceSiteId: siteId);
await using (var seedContext = CreateMsSqlContext())
{
var seedRepo = new AuditLogRepository(seedContext);
await seedRepo.InsertIfNotExistsAsync(oldEvt);
await seedRepo.InsertIfNotExistsAsync(keptEvt);
}
// Wire the actor's DI scope to the real repository against the
// fixture's MSSQL database. The actor opens a fresh scope per tick,
// so register the context as scoped (mirroring the production
// AddConfigurationDatabase wiring).
var services = new ServiceCollection();
services.AddDbContext<ScadaBridgeDbContext>(
opts => opts.UseSqlServer(_fixture.ConnectionString),
ServiceLifetime.Scoped);
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
var sp = services.BuildServiceProvider();
var auditOptions = new AuditLogOptions { RetentionDays = retentionDays };
var purgeOptions = new AuditLogPurgeOptions
{
IntervalHours = 24,
IntervalOverride = TimeSpan.FromMilliseconds(100),
};
var probe = SubscribePurged();
var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
sp,
Options.Create(purgeOptions),
Options.Create(auditOptions),
NullLogger<AuditLogPurgeActor>.Instance)));
// Fish for the January 2026 partition boundary — the only eligible one in this
// fixture DB. The generous timeout covers the real drop-and-rebuild dance
// against MSSQL which routinely takes a couple of seconds on a busy dev container.
var janBoundary = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var matched = probe.FishForMessage<AuditLogPurgedEvent>(
isMessage: m => m.MonthBoundary == janBoundary,
max: TimeSpan.FromSeconds(30));
// This is the one actor in the class that mutates the SHARED fixture database, so it
// must not outlive the test and start switching partitions out from under the next
// class's seed rows (register row 95).
StopPurgeActor(actor);
Assert.True(matched.RowsDeleted >= 1,
$"Expected RowsDeleted >= 1 for the Jan-2026 partition; got {matched.RowsDeleted}.");
// Settle: allow any in-flight tick to commit before reading.
await Task.Delay(TimeSpan.FromMilliseconds(500));
await using var verifyContext = CreateMsSqlContext();
var rows = await verifyContext.Set<AuditLogRow>()
.Where(e => e.SourceSiteId == siteId)
.ToListAsync();
Assert.DoesNotContain(rows, r => r.EventId == oldEvt.EventId);
Assert.Contains(rows, r => r.EventId == keptEvt.EventId);
}
private ScadaBridgeDbContext CreateMsSqlContext() =>
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString).Options);
// ---------------------------------------------------------------------
// 7. Threshold_UsesAuditLogOptionsRetentionDays
// ---------------------------------------------------------------------
/// <summary>Verifies that the purge threshold is derived from the configured audit log retention days.</summary>
[Fact]
public void Threshold_UsesAuditLogOptionsRetentionDays()
{
// The actor computes the threshold from AuditLogOptions.RetentionDays;
// assert the enumerator received a threshold whose value is in the
// expected window (today - retentionDays) rather than DateTime.MinValue
// or some other accidental default. We use a non-default retention
// (30 days) so the assertion isn't satisfied by the 365 default.
var repo = new RecordingRepo();
var actor = CreateActor(
repo,
FastTickOptions(),
auditOptions: new AuditLogOptions { RetentionDays = 30 });
AwaitAssert(
() => Assert.True(repo.ThresholdQueries.Count >= 1),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
StopPurgeActor(actor);
var threshold = repo.ThresholdQueries[0];
var expected = DateTime.UtcNow - TimeSpan.FromDays(30);
// 1-minute slack covers test-thread scheduling jitter between the
// tick firing and the assertion running.
Assert.True(
Math.Abs((threshold - expected).TotalMinutes) < 1.0,
$"threshold {threshold:o} should be within 1 minute of {expected:o}");
}
// ---------------------------------------------------------------------
// 8. PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge
// ---------------------------------------------------------------------
/// <summary>Verifies that a per-channel retention override shorter than the global window triggers a channel purge.</summary>
[Fact]
public void PerChannelOverride_ShorterThanGlobal_TriggersChannelPurge()
{
// ApiOutbound has a 30-day override under a 365-day global window — strictly
// shorter, so the actor must run a per-channel purge with a threshold of
// ~today-30d and the configured batch size.
var repo = new RecordingRepo { Boundaries = new List<DateTime>() };
var purgeOptions = FastTickOptions();
purgeOptions.ChannelPurgeBatchSizeConfigured = 1234;
purgeOptions.MaxPurgedRowsPerChannelPerTick = 4321;
// Build the options OUTSIDE the Props expression tree — a collection/dictionary
// initializer is not legal inside an expression-tree lambda (CS8074).
var auditOptions = Options.Create(new AuditLogOptions
{
RetentionDays = 365,
PerChannelRetentionDays = new Dictionary<string, int> { ["ApiOutbound"] = 30 },
});
var purgeOptionsWrapped = Options.Create(purgeOptions);
var sp = BuildScopedProvider(repo);
var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
sp,
purgeOptionsWrapped,
auditOptions,
NullLogger<AuditLogPurgeActor>.Instance)));
AwaitAssert(
() => Assert.Contains(repo.ChannelPurges, p => p.Channel == "ApiOutbound"),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
StopPurgeActor(actor);
var purge = repo.ChannelPurges.First(p => p.Channel == "ApiOutbound");
Assert.Equal(1234, purge.BatchSize);
Assert.Equal(4321, purge.MaxRows);
var expected = DateTime.UtcNow - TimeSpan.FromDays(30);
Assert.True(
Math.Abs((purge.Threshold - expected).TotalMinutes) < 1.0,
$"channel threshold {purge.Threshold:o} should be within 1 minute of {expected:o}");
}
// ---------------------------------------------------------------------
// 9. PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge
// ---------------------------------------------------------------------
/// <summary>Verifies that a per-channel retention override equal to or longer than the global window does not trigger a channel purge.</summary>
[Fact]
public void PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge()
{
// DbOutbound = 365 (== global) and Notification = 400 (> global, validator would
// normally reject this but the actor must defensively skip it too). Neither is
// SHORTER than the global window, so the actor must NOT issue a channel purge —
// the global partition switch-out already governs those rows.
var repo = new RecordingRepo { Boundaries = new List<DateTime>() };
// Build the options OUTSIDE the Props expression tree (CS8074).
var auditOptions = Options.Create(new AuditLogOptions
{
RetentionDays = 365,
PerChannelRetentionDays = new Dictionary<string, int>
{
["DbOutbound"] = 365,
["Notification"] = 400,
},
});
var purgeOptions = Options.Create(FastTickOptions());
var sp = BuildScopedProvider(repo);
var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
sp,
purgeOptions,
auditOptions,
NullLogger<AuditLogPurgeActor>.Instance)));
// Wait for at least one tick (visible via the enumerator call), then assert no
// channel purge was issued.
AwaitAssert(
() => Assert.True(repo.ThresholdQueries.Count >= 1),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
// Stop before the negative assertion: a still-ticking actor is exactly what would
// make it read a moving target (register row 95).
StopPurgeActor(actor);
Assert.Empty(repo.ChannelPurges);
}
// ---------------------------------------------------------------------
// 10. PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository
// ---------------------------------------------------------------------
/// <summary>Verifies that a purge tick passes the configured maintenance command timeout to the repository.</summary>
[Fact]
public void PurgeTick_Passes_MaintenanceCommandTimeout_To_Repository()
{
// The actor must thread AuditLogPurgeOptions.ResolvedMaintenanceCommandTimeout through to
// BOTH the partition switch-out and each per-channel purge, so the ~30s ADO.NET default
// cannot self-lock the drop-and-rebuild dance (arch-review 04, S2).
var boundary = new DateTime(2025, 5, 1, 0, 0, 0, DateTimeKind.Utc);
var repo = new RecordingRepo
{
Boundaries = new List<DateTime> { boundary },
RowsPerBoundary = _ => 7L,
};
var purgeOptions = FastTickOptions();
purgeOptions.MaintenanceCommandTimeoutMinutes = 45;
// A per-channel override shorter than the global window so the channel purge also fires.
var auditOptions = Options.Create(new AuditLogOptions
{
RetentionDays = 365,
PerChannelRetentionDays = new Dictionary<string, int> { ["ApiOutbound"] = 30 },
});
var sp = BuildScopedProvider(repo);
var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
sp,
Options.Create(purgeOptions),
auditOptions,
NullLogger<AuditLogPurgeActor>.Instance)));
var expected = TimeSpan.FromMinutes(45);
AwaitAssert(
() =>
{
Assert.Contains(expected, repo.SwitchTimeouts);
Assert.Contains(expected, repo.ChannelTimeouts);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
StopPurgeActor(actor);
}
// ---------------------------------------------------------------------
// 11. SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter
// ---------------------------------------------------------------------
/// <summary>Verifies that a failing switch-out publishes a purge-failed event and increments the health failure counter.</summary>
[Fact]
public void SwitchThrows_PublishesPurgeFailedEvent_AndIncrementsHealthCounter()
{
// A silently failing retention job is a health-surface condition, not just a log line:
// the per-boundary catch must publish AuditLogPurgeFailedEvent AND bump the
// AuditCentralHealthSnapshot.PurgeFailures counter (arch-review 04, S2).
var boundary = new DateTime(2025, 9, 1, 0, 0, 0, DateTimeKind.Utc);
var repo = new RecordingRepo
{
Boundaries = new List<DateTime> { boundary },
ThrowOnBoundary = boundary,
BoundaryException = new InvalidOperationException("simulated switch timeout"),
};
var snapshot = new AuditCentralHealthSnapshot();
var services = new ServiceCollection();
services.AddScoped<IAuditLogRepository>(_ => repo);
services.AddSingleton(snapshot);
services.AddSingleton<IAuditPurgeFailureCounter>(snapshot);
var sp = services.BuildServiceProvider();
var probe = CreateTestProbe();
Sys.EventStream.Subscribe(probe.Ref, typeof(AuditLogPurgeFailedEvent));
var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
sp,
Options.Create(FastTickOptions()),
Options.Create(new AuditLogOptions()),
NullLogger<AuditLogPurgeActor>.Instance)));
var evt = probe.ExpectMsg<AuditLogPurgeFailedEvent>(TimeSpan.FromSeconds(5));
Assert.Equal(boundary, evt.MonthBoundary);
Assert.Contains("simulated switch timeout", evt.Error);
Assert.True(evt.ElapsedMilliseconds >= 0);
AwaitAssert(
() => Assert.True(snapshot.PurgeFailures >= 1,
$"expected PurgeFailures >= 1, got {snapshot.PurgeFailures}"),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
StopPurgeActor(actor);
}
// ---------------------------------------------------------------------
// Parquet cold archive (ScadaBridge#42)
// ---------------------------------------------------------------------
private IActorRef CreateArchivingActor(RecordingRepo repo, IAuditLogArchive archive, bool enabled)
{
var services = new ServiceCollection();
services.AddScoped<IAuditLogRepository>(_ => repo);
services.AddScoped(_ => archive);
services.AddSingleton(Options.Create(new ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance.AuditLogArchiveOptions
{
Enabled = enabled,
Directory = "/archive",
}));
var sp = services.BuildServiceProvider();
return Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
sp,
Options.Create(FastTickOptions(TimeSpan.FromHours(1))),
Options.Create(new AuditLogOptions()),
NullLogger<AuditLogPurgeActor>.Instance)));
}
private static AuditArchiveManifest ManifestFor(DateTime boundary) => new(
1, $"auditlog-{boundary:yyyy-MM}-20261109T120000000Z", boundary, boundary.ToString("yyyy-MM"),
DateTime.UtcNow, AuditArchiveStatus.Archived, null, 7, 7, 0, null, null,
"d.parquet", "00", 99, "t.parquet", "00", 0, []);
/// <summary>
/// With archiving enabled every expired partition is archived and then purged by the
/// archive's guarded purge — never by the plain, unguarded switch.
/// </summary>
[Fact]
public void ArchiveEnabled_ArchivesEachPartition_ThenPurgesThroughTheArchive()
{
var boundary = new DateTime(2025, 10, 1, 0, 0, 0, DateTimeKind.Utc);
var repo = new RecordingRepo { Boundaries = [boundary] };
var archive = Substitute.For<IAuditLogArchive>();
var manifest = ManifestFor(boundary);
archive.ArchivePartitionAsync(boundary, Arg.Any<CancellationToken>()).Returns(manifest);
archive.PurgeArchivedPartitionAsync(manifest, Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
.Returns(7L);
var archived = CreateTestProbe();
Sys.EventStream.Subscribe(archived.Ref, typeof(AuditLogArchivedEvent));
var purged = SubscribePurged();
var actor = CreateArchivingActor(repo, archive, enabled: true);
actor.Tell(AuditLogPurgeActor.PurgeTick.Instance);
var a = archived.ExpectMsg<AuditLogArchivedEvent>(TimeSpan.FromSeconds(5));
Assert.Equal(manifest.Name, a.ArchiveName);
Assert.Equal(7, purged.ExpectMsg<AuditLogPurgedEvent>(TimeSpan.FromSeconds(5)).RowsDeleted);
Assert.Empty(repo.SwitchedBoundaries);
archive.Received(1).PurgeArchivedPartitionAsync(manifest, Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>());
StopPurgeActor(actor);
}
/// <summary>
/// Fail closed: a partition that could not be archived is not purged at all, and the
/// failure surfaces as a purge failure.
/// </summary>
[Fact]
public void ArchiveFailure_LeavesThePartitionInPlace_AndPublishesAFailure()
{
var boundary = new DateTime(2025, 10, 1, 0, 0, 0, DateTimeKind.Utc);
var repo = new RecordingRepo { Boundaries = [boundary] };
var archive = Substitute.For<IAuditLogArchive>();
archive.ArchivePartitionAsync(boundary, Arg.Any<CancellationToken>())
.Returns<Task<AuditArchiveManifest>>(_ => throw new IOException("archive volume full"));
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.Equal(boundary, evt.MonthBoundary);
Assert.Contains("archive volume full", evt.Error);
Assert.Empty(repo.SwitchedBoundaries);
archive.DidNotReceiveWithAnyArgs().PurgeArchivedPartitionAsync(default!, default, default);
StopPurgeActor(actor);
}
/// <summary>With archiving configured but disabled, the purge is the plain switch and the archive is untouched.</summary>
[Fact]
public void ArchiveDisabled_UsesThePlainSwitch()
{
var boundary = new DateTime(2025, 10, 1, 0, 0, 0, DateTimeKind.Utc);
var repo = new RecordingRepo { Boundaries = [boundary] };
var archive = Substitute.For<IAuditLogArchive>();
var purged = SubscribePurged();
var actor = CreateArchivingActor(repo, archive, enabled: false);
actor.Tell(AuditLogPurgeActor.PurgeTick.Instance);
purged.ExpectMsg<AuditLogPurgedEvent>(TimeSpan.FromSeconds(5));
Assert.Equal([boundary], repo.SwitchedBoundaries);
archive.DidNotReceiveWithAnyArgs().ArchivePartitionAsync(default, default);
StopPurgeActor(actor);
}
/// <summary>
/// ScadaBridge#107 item 3: when the host registered the maintenance-connection repository
/// (keyed <c>audit-maintenance</c>), the purge runs on it, never on the runtime one.
/// </summary>
[Fact]
public void Tick_UsesTheMaintenanceConnectionRepository_WhenRegistered()
{
var runtime = new RecordingRepo { Boundaries = [new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)] };
var maintenance = new RecordingRepo { Boundaries = [new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)] };
var services = new ServiceCollection();
services.AddScoped<IAuditLogRepository>(_ => runtime);
services.AddKeyedScoped<IAuditLogRepository>(
ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance.AuditMaintenanceContext.ServiceKey, (_, _) => maintenance);
var sp = services.BuildServiceProvider();
var actor = Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor(
sp,
Options.Create(FastTickOptions()),
Options.Create(new AuditLogOptions()),
NullLogger<AuditLogPurgeActor>.Instance)));
AwaitAssert(
() => Assert.NotEmpty(maintenance.SwitchedBoundaries),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
Assert.Empty(runtime.ThresholdQueries);
Assert.Empty(runtime.SwitchedBoundaries);
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);
}
}