feat(audit): M5.5 per-channel retention overrides via purge-role bounded delete (T3)
This commit is contained in:
@@ -67,19 +67,25 @@ public class PartitionPurgeTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||
SqlConnection conn,
|
||||
Guid eventId,
|
||||
DateTime occurredAtUtc,
|
||||
string siteId)
|
||||
string siteId,
|
||||
string channel = "ApiOutbound",
|
||||
string kind = "ApiCall")
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
// C5 (Task 2.5): dbo.AuditLog is now the 10 canonical columns + DetailsJson;
|
||||
// the ScadaBridge domain fields (channel/kind/status/sourceSiteId) ride in
|
||||
// DetailsJson and the SourceSiteId/Kind/Status computed columns auto-derive.
|
||||
// Action = "{channel}.{kind}", Category = channel name, Outcome = Success.
|
||||
// The channel/kind are parameterized so the M5.5 per-channel purge test can
|
||||
// seed multiple channels into the same partition.
|
||||
cmd.CommandText = @"
|
||||
INSERT INTO dbo.AuditLog
|
||||
(EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson)
|
||||
VALUES
|
||||
(@EventId, @OccurredAtUtc, NULL, 'ApiOutbound.ApiCall', 'Success', 'ApiOutbound', NULL, NULL, NULL,
|
||||
(@EventId, @OccurredAtUtc, NULL, @Action, 'Success', @Category, NULL, NULL, NULL,
|
||||
@DetailsJson);";
|
||||
cmd.Parameters.Add("@Action", System.Data.SqlDbType.VarChar, 64).Value = $"{channel}.{kind}";
|
||||
cmd.Parameters.Add("@Category", System.Data.SqlDbType.VarChar, 32).Value = channel;
|
||||
cmd.Parameters.Add("@EventId", System.Data.SqlDbType.UniqueIdentifier).Value = eventId;
|
||||
// SqlDbType.DateTime2 with explicit Scale 7 matches the
|
||||
// OccurredAtUtc column shape (datetime2(7)) and avoids the implicit
|
||||
@@ -97,7 +103,7 @@ VALUES
|
||||
// the computed SourceSiteId column the verify queries scope on. payloadTruncated
|
||||
// is always present (the codec always writes the bool).
|
||||
var detailsJson =
|
||||
"{\"channel\":\"ApiOutbound\",\"kind\":\"ApiCall\",\"status\":\"Delivered\"," +
|
||||
"{\"channel\":\"" + channel + "\",\"kind\":\"" + kind + "\",\"status\":\"Delivered\"," +
|
||||
"\"sourceSiteId\":\"" + siteId + "\",\"payloadTruncated\":false}";
|
||||
cmd.Parameters.Add("@DetailsJson", System.Data.SqlDbType.NVarChar, -1).Value = detailsJson;
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
@@ -354,4 +360,87 @@ WHERE name = 'UX_AuditLog_EventId'
|
||||
Assert.Single(rows);
|
||||
Assert.Equal(freshEventId, rows[0].EventId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 4. PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows (M5.5 T3)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// M5.5 (T3): exercises <see cref="IAuditLogRepository.PurgeChannelOlderThanAsync"/>
|
||||
/// directly against the real repository + fixture DB. Seeds, in the SAME partition,
|
||||
/// old + recent rows for an OVERRIDDEN channel (<c>ApiOutbound</c>) and old + recent
|
||||
/// rows for an UN-overridden channel (<c>DbOutbound</c>), then runs the per-channel
|
||||
/// purge for <c>ApiOutbound</c> only. Asserts:
|
||||
/// <list type="number">
|
||||
/// <item>The overridden channel's OLD rows are deleted.</item>
|
||||
/// <item>The overridden channel's RECENT rows (newer than the channel threshold) survive.</item>
|
||||
/// <item>The un-overridden channel's rows (old AND recent) are completely untouched
|
||||
/// — they follow the global window, which the channel purge never applies to them.</item>
|
||||
/// </list>
|
||||
/// This is the maintenance-path row DELETE; the fixture connects as <c>sa</c>, which
|
||||
/// the append-only writer-role DENYs do not bind (the role granularity is exercised
|
||||
/// in the repository/migration tests).
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteId = "perchannel-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
|
||||
// Two timestamps: one OLD (older than the channel threshold we will purge with)
|
||||
// and one RECENT (newer than it). Both sit comfortably inside the retention
|
||||
// window so the global partition purge would NOT touch either — isolating the
|
||||
// per-channel DELETE as the only force acting here.
|
||||
var oldOccurred = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc);
|
||||
var recentOccurred = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var apiOldId = Guid.NewGuid(); // ApiOutbound, old → SHOULD be deleted
|
||||
var apiRecentId = Guid.NewGuid(); // ApiOutbound, recent→ SHOULD survive
|
||||
var dbOldId = Guid.NewGuid(); // DbOutbound, old → SHOULD survive (un-overridden)
|
||||
var dbRecentId = Guid.NewGuid(); // DbOutbound, recent → SHOULD survive
|
||||
|
||||
await using (var seedConn = _fixture.OpenConnection())
|
||||
{
|
||||
await DirectInsertAsync(seedConn, apiOldId, oldOccurred, siteId, channel: "ApiOutbound", kind: "ApiCall");
|
||||
await DirectInsertAsync(seedConn, apiRecentId, recentOccurred, siteId, channel: "ApiOutbound", kind: "ApiCall");
|
||||
await DirectInsertAsync(seedConn, dbOldId, oldOccurred, siteId, channel: "DbOutbound", kind: "DbWrite");
|
||||
await DirectInsertAsync(seedConn, dbRecentId, recentOccurred, siteId, channel: "DbOutbound", kind: "DbWrite");
|
||||
}
|
||||
|
||||
// Purge ApiOutbound rows older than a threshold that sits strictly between the
|
||||
// old (Jan 15) and recent (May 15) seeds — e.g. Mar 1. Only apiOldId qualifies.
|
||||
var channelThreshold = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
await using (var ctx = CreateContext())
|
||||
{
|
||||
var repo = new AuditLogRepository(ctx);
|
||||
var deleted = await repo.PurgeChannelOlderThanAsync(
|
||||
channel: "ApiOutbound",
|
||||
threshold: channelThreshold,
|
||||
batchSize: 2);
|
||||
|
||||
Assert.Equal(1L, deleted);
|
||||
|
||||
// Idempotent: a second run deletes nothing (the eligible row is gone).
|
||||
var deletedAgain = await repo.PurgeChannelOlderThanAsync(
|
||||
channel: "ApiOutbound",
|
||||
threshold: channelThreshold,
|
||||
batchSize: 2);
|
||||
Assert.Equal(0L, deletedAgain);
|
||||
}
|
||||
|
||||
await using var verify = CreateContext();
|
||||
var rows = await verify.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
// Overridden channel: old gone, recent kept.
|
||||
Assert.DoesNotContain(rows, r => r.EventId == apiOldId);
|
||||
Assert.Contains(rows, r => r.EventId == apiRecentId);
|
||||
|
||||
// Un-overridden channel: BOTH rows untouched (follow the global window).
|
||||
Assert.Contains(rows, r => r.EventId == dbOldId);
|
||||
Assert.Contains(rows, r => r.EventId == dbRecentId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user