fix(configdb): opt-in config-audit retention purge + after-state size cap (review 2026-10-21 08/G1)

This commit is contained in:
Joseph Doherty
2026-10-22 08:32:40 -04:00
parent 394234b450
commit 036f341a9d
7 changed files with 292 additions and 2 deletions
@@ -212,4 +212,20 @@ public class AuditServiceTests : IDisposable
Assert.Contains("node", audit.AfterStateJson);
Assert.Single(await _context.Templates.Where(t => t.Name == "CyclicAuditTemplate").ToListAsync());
}
/// <summary>An oversized after-state is replaced by a small valid-JSON truncation marker (review 08/G1).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogAsync_OversizedAfterState_IsTruncatedToMarker()
{
await _auditService.LogAsync("admin", "Update", "Template", "1", "Big",
new { Code = new string('x', AuditService.MaxAfterStateChars + 10) });
await _context.SaveChangesAsync();
var json = _context.AuditLogEntries.Single().AfterStateJson!;
Assert.True(json.Length < 16 * 1024);
using var doc = JsonDocument.Parse(json);
Assert.True(doc.RootElement.GetProperty("AuditTruncated").GetBoolean());
Assert.True(doc.RootElement.GetProperty("OriginalLength").GetInt32() > AuditService.MaxAfterStateChars);
}
}
@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance;
/// <summary>Tests for the opt-in config-audit retention purge (review 2026-10-21 08/G1).</summary>
public class ConfigAuditPurgeServiceTests : IDisposable
{
private readonly ScadaBridgeDbContext _context = SqliteTestHelper.CreateInMemoryContext();
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
private ConfigAuditPurgeService Create(ConfigAuditOptions opts)
{
var services = new ServiceCollection();
services.AddSingleton(_context);
return new ConfigAuditPurgeService(
services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>(),
Options.Create(opts),
TimeProvider.System,
NullLogger<ConfigAuditPurgeService>.Instance);
}
private async Task SeedAsync(int oldCount, int recentCount)
{
var now = DateTimeOffset.UtcNow;
for (var i = 0; i < oldCount; i++)
_context.AuditLogEntries.Add(new AuditLogEntry("u", "Create", "Template", $"o{i}", $"Old{i}") { Timestamp = now.AddDays(-100).AddMinutes(i) });
for (var i = 0; i < recentCount; i++)
_context.AuditLogEntries.Add(new AuditLogEntry("u", "Create", "Template", $"r{i}", $"New{i}") { Timestamp = now.AddDays(-1) });
await _context.SaveChangesAsync();
}
[Fact]
public async Task RetentionZero_DeletesNothing()
{
await SeedAsync(5, 2);
var deleted = await Create(new ConfigAuditOptions { RetentionDays = 0 }).PurgeOnceAsync();
Assert.Equal(0, deleted);
Assert.Equal(7, _context.AuditLogEntries.Count());
}
[Fact]
public async Task Retention_DeletesOnlyOlderThanWindow_AcrossBatches()
{
await SeedAsync(25, 3);
var deleted = await Create(new ConfigAuditOptions { RetentionDays = 30, PurgeBatchSize = 10 }).PurgeOnceAsync();
Assert.Equal(25, deleted);
Assert.Equal(3, _context.AuditLogEntries.Count());
}
[Fact]
public async Task Retention_MaxBatchesPerTick_TimeSlicesTheBacklog()
{
await SeedAsync(25, 0);
var svc = Create(new ConfigAuditOptions { RetentionDays = 30, PurgeBatchSize = 10, MaxBatchesPerTick = 2 });
Assert.Equal(20, await svc.PurgeOnceAsync());
Assert.Equal(5, await svc.PurgeOnceAsync());
}
[Fact]
public void Validator_RejectsNegativeRetention_AcceptsZero()
{
var v = new ConfigAuditOptionsValidator();
Assert.True(v.Validate(null, new ConfigAuditOptions { RetentionDays = 0 }).Succeeded);
Assert.True(v.Validate(null, new ConfigAuditOptions { RetentionDays = 90 }).Succeeded);
Assert.False(v.Validate(null, new ConfigAuditOptions { RetentionDays = -1 }).Succeeded);
Assert.False(v.Validate(null, new ConfigAuditOptions { PurgeBatchSize = 0 }).Succeeded);
}
}