using System.Data; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.Audit; 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 integration tests for the hash-chain cutoff (ScadaBridge#139): enabling the /// chain on a database with history chains only rows from the enablement instant on (owner /// decision on #107, 2026-11-13), verification treats the backlog as out of scope, the /// tamper cases are still caught, moving a row (or the cutoff) across the cutoff is caught, /// both purge paths keep the chain verifiable across the cutoff, and both central nodes /// settle on one cutoff. /// /// /// The cutoff is one row per database, so every test starts from an empty AuditLog and empty /// chain tables (). xunit runs a class's tests sequentially. /// public sealed class AuditLogHashChainCutoffTests : IClassFixture, IDisposable { private static readonly DateTime Backlog = new(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc); private static readonly DateTime CutoffMonth = new(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc); private static readonly DateTime After = new(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc); private static readonly DateTime Cutoff = new(2026, 3, 15, 12, 0, 0, DateTimeKind.Utc); private readonly MsSqlMigrationFixture _fixture; private readonly DirectoryInfo _archiveRoot = Directory.CreateTempSubdirectory("auditlog-cutoff-archive-"); /// Creates the test class with its own migrated SQL Server database. /// The per-class migration fixture. public AuditLogHashChainCutoffTests(MsSqlMigrationFixture fixture) => _fixture = fixture; /// public void Dispose() => _archiveRoot.Delete(recursive: true); /// /// The owner's decision end to end: a backlog exists, the chain is enabled, only rows dated /// from the cutoff on are chained, the backlog is untouched, and every month verifies — /// the backlog as pre-cutoff rows, never as unchained rows or a break. /// /// A task that represents the asynchronous operation. [SkippableFact] public async Task FirstEnablement_ChainsOnlyFromTheCutoff_BacklogUntouched_VerifiesIntact() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await ResetAsync(); var backlog = Rows(Backlog.AddDays(3), 4).Concat(Rows(CutoffMonth.AddDays(2), 3)).ToList(); await InsertAsync(backlog); await DrainAsync(); // first enablement: records the anchor at Cutoff, chains nothing Assert.Equal(Cutoff, await AnchorCutoffAsync()); Assert.Equal(0L, await ScalarAsync("SELECT COUNT_BIG(*) FROM dbo.AuditLog WHERE ChainSeq IS NOT NULL;")); Assert.Equal(0L, await ScalarAsync("SELECT COUNT_BIG(*) FROM dbo.AuditLogChainHead;")); var post = Rows(Cutoff.AddMinutes(1), 5).Concat(Rows(After.AddDays(1), 2)).ToList(); await InsertAsync(post); await DrainAsync(); // Every backlog row stays exactly as it was: unchained. foreach (var e in backlog) { Assert.Equal(DBNull.Value, await ScalarAsync($"SELECT ChainSeq FROM dbo.AuditLog WHERE EventId = '{e.EventId}';")); } var march = await VerifyAsync(CutoffMonth); Assert.Equal(AuditChainStatus.Intact, march.Status); Assert.Equal(5, march.RowsVerified); Assert.Equal(0, march.UnchainedRows); Assert.Equal(3, march.PreCutoffRows); Assert.Equal(Cutoff, march.ChainFromUtc); var february = await VerifyAsync(Backlog); Assert.Equal(AuditChainStatus.NotChained, february.Status); Assert.Equal(0, february.UnchainedRows); Assert.Equal(4, february.PreCutoffRows); var april = await VerifyAsync(After); Assert.Equal(AuditChainStatus.Intact, april.Status); Assert.Equal(2, april.RowsVerified); Assert.Equal(0, april.PreCutoffRows); // A late site row dated before the cutoff that reaches central after enablement is // keyed on OccurredAtUtc: it stays out of scope (documented limit), not pending. await InsertAsync(Rows(Cutoff.AddHours(-2), 1)); await DrainAsync(); march = await VerifyAsync(CutoffMonth); Assert.Equal(AuditChainStatus.Intact, march.Status); Assert.Equal(4, march.PreCutoffRows); Assert.Equal(0, march.UnchainedRows); } /// /// The cutoff month's first row links to the anchor, not to 32 zero bytes, and the /// ordinary tamper cases are still caught after a cutoff start: edit, delete, tail delete. /// /// A task that represents the asynchronous operation. [SkippableFact] public async Task TamperCases_AreStillDetected_AfterACutoffStart() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var events = await SeedCutoffChainAsync(postRows: 6); Assert.Equal(AuditChainStatus.Intact, (await VerifyAsync(CutoffMonth)).Status); // Edit the FIRST link (the one hashed against the anchor). await ExecAsync($"UPDATE dbo.AuditLog SET Target = N'forged' WHERE EventId = '{events[0]}';"); var edited = await VerifyAsync(CutoffMonth); Assert.Equal(AuditChainStatus.Broken, edited.Status); Assert.Equal(AuditChainBreakKind.HashMismatch, edited.FirstBreak!.Kind); Assert.Equal(1, edited.FirstBreak.ChainSeq); Assert.Equal(events[0], edited.FirstBreak.EventId); await ExecAsync($"UPDATE dbo.AuditLog SET Target = N'erp' WHERE EventId = '{events[0]}';"); Assert.Equal(AuditChainStatus.Intact, (await VerifyAsync(CutoffMonth)).Status); var middleSeq = await SeqOfAsync(events[2]); await ExecAsync($"DELETE FROM dbo.AuditLog WHERE EventId = '{events[2]}';"); var deleted = await VerifyAsync(CutoffMonth); Assert.Equal(AuditChainStatus.Broken, deleted.Status); Assert.Equal(AuditChainBreakKind.MissingRow, deleted.FirstBreak!.Kind); Assert.Equal(middleSeq, deleted.FirstBreak.ChainSeq); await ResetAsync(); events = await SeedCutoffChainAsync(postRows: 4); await ExecAsync($"DELETE FROM dbo.AuditLog WHERE EventId = '{events[^1]}';"); var tail = await VerifyAsync(CutoffMonth); Assert.Equal(AuditChainStatus.Broken, tail.Status); Assert.Equal(AuditChainBreakKind.HeadMismatch, tail.FirstBreak!.Kind); } /// /// The cutoff-eligibility attack: move a chained row's timestamp back before the cutoff to /// hide it among the unprotected backlog rows. Caught with its ChainSeq left in place /// (), and with its link cleared too /// (its sequence number goes missing). Moving it into an earlier month is caught in both months. /// /// A task that represents the asynchronous operation. [SkippableFact] public async Task ChainedRowMovedBeforeTheCutoff_IsDetected() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var events = await SeedCutoffChainAsync(postRows: 5); var victim = events[2]; var victimSeq = await SeqOfAsync(victim); // 1. Timestamp moved back across the cutoff, link left in place. await ExecAsync($"UPDATE dbo.AuditLog SET OccurredAtUtc = '{Cutoff.AddHours(-1):yyyy-MM-dd HH:mm:ss}' WHERE EventId = '{victim}';"); var moved = await VerifyAsync(CutoffMonth); Assert.Equal(AuditChainStatus.Broken, moved.Status); Assert.Equal(AuditChainBreakKind.ChainedBeforeCutoff, moved.FirstBreak!.Kind); Assert.Equal(victim, moved.FirstBreak.EventId); Assert.Equal(victimSeq, moved.FirstBreak.ChainSeq); // 2. ...and its link cleared, so it looks like any other pre-cutoff row. await ExecAsync($"UPDATE dbo.AuditLog SET ChainSeq = NULL, RowHash = NULL WHERE EventId = '{victim}';"); var hidden = await VerifyAsync(CutoffMonth); Assert.Equal(AuditChainStatus.Broken, hidden.Status); Assert.Equal(AuditChainBreakKind.MissingRow, hidden.FirstBreak!.Kind); Assert.Equal(victimSeq, hidden.FirstBreak.ChainSeq); Assert.Equal(4, hidden.PreCutoffRows); // the 3 backlog rows + the hidden one // 3. Moved into the previous (backlog) month with its link: caught in both months. await ResetAsync(); events = await SeedCutoffChainAsync(postRows: 5); victim = events[1]; await ExecAsync($"UPDATE dbo.AuditLog SET OccurredAtUtc = '{Backlog.AddDays(9):yyyy-MM-dd HH:mm:ss}' WHERE EventId = '{victim}';"); Assert.Equal(AuditChainBreakKind.MissingRow, (await VerifyAsync(CutoffMonth)).FirstBreak!.Kind); var february = await VerifyAsync(Backlog); Assert.Equal(AuditChainStatus.Broken, february.Status); Assert.Equal(AuditChainBreakKind.ChainedBeforeCutoff, february.FirstBreak!.Kind); Assert.Equal(victim, february.FirstBreak.EventId); } /// /// The other half of the eligibility attack: moving the cutoff itself. Editing the anchor's /// cutoff, re-hashing the anchor to match, deleting it, or adding a second anchor are each /// reported, so the backlog/scope boundary cannot be shifted silently. /// /// A task that represents the asynchronous operation. [SkippableFact] public async Task CutoffAnchorTampering_IsDetected() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await SeedCutoffChainAsync(postRows: 4); var later = Cutoff.AddDays(5); // Cutoff moved forward, anchor hash left alone. await ExecAsync($"UPDATE dbo.AuditLogChainTombstone SET PurgedAtUtc = '{later:yyyy-MM-dd HH:mm:ss}' WHERE ChainSeq = 0;"); var moved = await VerifyAsync(CutoffMonth); Assert.Equal(AuditChainStatus.Broken, moved.Status); Assert.Equal(AuditChainBreakKind.CutoffAnchorInvalid, moved.FirstBreak!.Kind); Assert.Equal(AuditChainStatus.Broken, (await VerifyAsync(After)).Status); // every month sees it // Cutoff moved forward AND the anchor re-hashed to match: the chained rows now precede it. await SetAnchorAsync(later, AuditChainHasher.ComputeCutoffAnchorHash(later)); var rehashed = await VerifyAsync(CutoffMonth); Assert.Equal(AuditChainStatus.Broken, rehashed.Status); Assert.Equal(AuditChainBreakKind.ChainedBeforeCutoff, rehashed.FirstBreak!.Kind); // Anchor deleted: the cutoff month's first link no longer starts from it. await ExecAsync("DELETE FROM dbo.AuditLogChainTombstone WHERE ChainSeq = 0;"); var deleted = await VerifyAsync(CutoffMonth); Assert.Equal(AuditChainStatus.Broken, deleted.Status); Assert.Equal(AuditChainBreakKind.HashMismatch, deleted.FirstBreak!.Kind); Assert.Equal(1, deleted.FirstBreak.ChainSeq); // Restored → intact (positive control); then a second anchor is reported. await SetAnchorAsync(Cutoff, AuditChainHasher.ComputeCutoffAnchorHash(Cutoff)); Assert.Equal(AuditChainStatus.Intact, (await VerifyAsync(CutoffMonth)).Status); var earlier = Backlog.AddDays(1); await ExecAsync( "INSERT INTO dbo.AuditLogChainTombstone (ChainMonth, ChainSeq, RowHash, EventId, PurgedAtUtc) " + $"VALUES ('{Backlog:yyyy-MM-dd}', 0, 0x{Convert.ToHexString(AuditChainHasher.ComputeCutoffAnchorHash(earlier))}, NEWID(), '{earlier:yyyy-MM-dd HH:mm:ss}');"); var twoAnchors = await VerifyAsync(CutoffMonth); Assert.Equal(AuditChainStatus.Broken, twoAnchors.Status); Assert.Equal(AuditChainBreakKind.CutoffAnchorInvalid, twoAnchors.FirstBreak!.Kind); } /// /// Both purge paths across the cutoff: a per-channel purge that deletes backlog and chained /// rows alike leaves an intact chain; the head store and an export of it agree (the anchor is /// not counted as a purge tombstone); the partition switch-out of the cutoff month keeps the /// anchor and anchors the month; the archive of that month verifies from its own files; and /// the chainer still honours the cutoff afterwards. /// /// A task that represents the asynchronous operation. [SkippableFact] public async Task PurgeAcrossTheCutoff_KeepsEveryChainVerifiable() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await ResetAsync(); var preDb = Rows(CutoffMonth.AddDays(1), 2, AuditChannel.DbOutbound); var preApi = Rows(CutoffMonth.AddDays(2), 2); await InsertAsync(preDb.Concat(preApi)); await DrainAsync(); var postDb = Rows(Cutoff.AddHours(1), 3, AuditChannel.DbOutbound); var postApi = Rows(Cutoff.AddHours(2), 3); await InsertAsync(postDb.Concat(postApi)); await DrainAsync(); // Per-channel purge with a threshold past the cutoff: 2 backlog + 3 chained DbOutbound rows. await using (var ctx = CreateContext()) { var purged = await new AuditLogRepository(ctx).PurgeChannelOlderThanAsync( nameof(AuditChannel.DbOutbound), Cutoff.AddDays(1), batchSize: 2); Assert.Equal(5, purged); } var afterChannelPurge = await VerifyAsync(CutoffMonth); Assert.Equal(AuditChainStatus.Intact, afterChannelPurge.Status); Assert.Equal(3, afterChannelPurge.TombstonesApplied); Assert.Equal(3, afterChannelPurge.RowsVerified); Assert.Equal(2, afterChannelPurge.PreCutoffRows); // Head store + export: the anchor is not a purge tombstone; an export verifies as Matches. await using (var ctx = CreateContext()) { var head = Assert.Single(await new AuditLogHashChain(ctx).ReadHeadsAsync()); Assert.Equal(3, head.Tombstones); Assert.Equal(3, head.ChainedRows); var exported = await new AuditLogHashChain(ctx).VerifyExportedHeadsAsync([Export(head)]); Assert.True(exported.Verified); Assert.Equal(AuditChainHeadStatus.Matches, Assert.Single(exported.Checks).Status); } // Archive the cutoff month, then switch it out through the archive guard. AuditArchiveManifest manifest; await using (var ctx = CreateContext()) { manifest = await Archive(ctx).ArchivePartitionAsync(CutoffMonth); await Archive(ctx).PurgeArchivedPartitionAsync(manifest); } Assert.Equal(Cutoff, await AnchorCutoffAsync()); // the anchor survives the switch-out var switched = await VerifyAsync(CutoffMonth); Assert.Equal(AuditChainStatus.Intact, switched.Status); Assert.Equal(6, switched.PurgedThroughSeq); Assert.Equal(0, switched.PreCutoffRows); await using (var ctx = CreateContext()) { var archived = await Archive(ctx).VerifyAsync(manifest.Name); Assert.True(archived.Verified, archived.FileProblem); var chain = Assert.Single(archived.Chains).Chain; Assert.Equal(AuditChainStatus.Intact, chain.Status); Assert.Equal(3, chain.RowsVerified); Assert.Equal(3, chain.TombstonesApplied); Assert.Equal(2, chain.PreCutoffRows); // the archive classifies its backlog rows too Assert.Equal(0, chain.UnchainedRows); Assert.Equal(Cutoff, chain.ChainFromUtc); } // After the purge the cutoff still holds: a late backlog row stays out, a late post-cutoff row chains on. var lateBacklog = Rows(Backlog.AddDays(4), 1); var latePost = Rows(Cutoff.AddHours(5), 1); await InsertAsync(lateBacklog.Concat(latePost)); await DrainAsync(); Assert.Equal(DBNull.Value, await ScalarAsync($"SELECT ChainSeq FROM dbo.AuditLog WHERE EventId = '{lateBacklog[0].EventId}';")); var late = await VerifyAsync(CutoffMonth); Assert.Equal(AuditChainStatus.Intact, late.Status); Assert.Equal(7, late.HeadSeq); Assert.Equal(1, (await VerifyAsync(Backlog)).PreCutoffRows); } /// /// Both central nodes settle on ONE cutoff: two chainers race the first enablement on a /// pristine database with the real database clock, exactly one anchor is written (inside the /// database clock window of the race), and a node configured to chain the backlog that runs /// after the decision cannot override it. /// /// A task that represents the asynchronous operation. [SkippableFact] public async Task BothNodes_AgreeOnOneCutoff() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await ResetAsync(); var backlog = Rows(CutoffMonth.AddDays(1), 5); await InsertAsync(backlog); var before = await ScalarAsync("SELECT SYSUTCDATETIME();"); var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); async Task Node() { await start.Task; for (var i = 0; i < 20; i++) { await using var ctx = CreateContext(); await new AuditLogHashChain(ctx).ChainPendingAsync(100); // production clock } } var nodes = new[] { Task.Run(Node), Task.Run(Node) }; start.SetResult(); await Task.WhenAll(nodes); var after = await ScalarAsync("SELECT SYSUTCDATETIME();"); Assert.Equal(1L, await ScalarAsync("SELECT COUNT_BIG(*) FROM dbo.AuditLogChainTombstone WHERE ChainSeq = 0;")); var cutoff = await AnchorCutoffAsync(); Assert.InRange(cutoff, before, after); // The peer node configured with ChainBacklog=true runs after the decision: still no backlog chained. await using (var ctx = CreateContext()) { Assert.Equal(0, (await new AuditLogHashChain(ctx).ChainPendingAsync(100, chainBacklog: true)).RowsChained); } Assert.Equal(0L, await ScalarAsync("SELECT COUNT_BIG(*) FROM dbo.AuditLog WHERE ChainSeq IS NOT NULL;")); Assert.Equal(1L, await ScalarAsync("SELECT COUNT_BIG(*) FROM dbo.AuditLogChainTombstone WHERE ChainSeq = 0;")); var verified = await VerifyAsync(AuditChainHasher.MonthOf(cutoff)); Assert.Equal(cutoff, verified.ChainFromUtc); } /// /// The old behaviour stays reachable and unchanged: ChainBacklog=true on a pristine database /// writes no anchor and chains everything, and a database that already holds a chain (legacy /// enablement, no anchor) keeps chaining every row even with ChainBacklog=false. /// /// A task that represents the asynchronous operation. [SkippableFact] public async Task ChainBacklog_AndLegacyChains_KeepChainingEverything() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await ResetAsync(); await InsertAsync(Rows(Backlog.AddDays(1), 3)); await DrainAsync(chainBacklog: true); Assert.Equal(0L, await ScalarAsync("SELECT COUNT_BIG(*) FROM dbo.AuditLogChainTombstone WHERE ChainSeq = 0;")); Assert.Equal(3L, await ScalarAsync("SELECT COUNT_BIG(*) FROM dbo.AuditLog WHERE ChainSeq IS NOT NULL;")); // Now a node on the default (no backlog) sees an existing chain: no anchor, old rows still chain. await InsertAsync(Rows(Backlog.AddDays(10), 2)); await DrainAsync(); Assert.Equal(0L, await ScalarAsync("SELECT COUNT_BIG(*) FROM dbo.AuditLogChainTombstone WHERE ChainSeq = 0;")); var february = await VerifyAsync(Backlog); Assert.Equal(AuditChainStatus.Intact, february.Status); Assert.Equal(5, february.RowsVerified); Assert.Null(february.ChainFromUtc); Assert.Equal(0, february.PreCutoffRows); } /// /// The hardened maintenance login (scadabridge_audit_chainer + scadabridge_audit_purger, /// ScadaBridge#107 item 3) can write the chain-start anchor: no extra grant, no migration. /// /// A task that represents the asynchronous operation. [SkippableFact] public async Task MaintenanceLogin_CanWriteTheAnchor() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); await ResetAsync(); var login = "sbc_" + Guid.NewGuid().ToString("N")[..10]; const string password = "Sb_W22_cutoff#2026"; await ExecAsync($@" CREATE LOGIN [{login}] WITH PASSWORD = N'{password}', CHECK_POLICY = OFF; CREATE USER [{login}] FOR LOGIN [{login}]; ALTER ROLE scadabridge_audit_chainer ADD MEMBER [{login}]; ALTER ROLE scadabridge_audit_purger ADD MEMBER [{login}];"); try { await InsertAsync(Rows(Backlog.AddDays(2), 2)); var maintenance = new SqlConnectionStringBuilder(_fixture.ConnectionString) { IntegratedSecurity = false, UserID = login, Password = password, Pooling = false, }.ConnectionString; await using (var ctx = new ScadaBridgeDbContext( new DbContextOptionsBuilder().UseSqlServer(maintenance).Options)) { var chain = new AuditLogHashChain(ctx) { AnchorClock = () => Cutoff }; Assert.Equal(0, (await chain.ChainPendingAsync(100)).RowsChained); } Assert.Equal(Cutoff, await AnchorCutoffAsync()); } finally { SqlConnection.ClearAllPools(); await ExecAsync($@" DECLARE @kill nvarchar(max) = N''; SELECT @kill = @kill + N'KILL ' + CAST(session_id AS nvarchar(10)) + N';' FROM sys.dm_exec_sessions WHERE login_name = N'{login}'; EXEC sp_executesql @kill; DROP USER [{login}]; DROP LOGIN [{login}];"); } } // ── helpers ────────────────────────────────────────────────────────────── /// /// Backlog in February and early March, first enablement at , then /// rows after it, all chained. Returns the post-cutoff EventIds in chain order. /// private async Task> SeedCutoffChainAsync(int postRows) { await ResetAsync(); await InsertAsync(Rows(Backlog.AddDays(3), 2).Concat(Rows(CutoffMonth.AddDays(2), 3))); await DrainAsync(); var post = Rows(Cutoff.AddMinutes(1), postRows); await InsertAsync(post); await DrainAsync(); return post.Select(e => e.EventId).ToList(); } private async Task ResetAsync() { if (!_fixture.Available) { return; } await ExecAsync("DELETE FROM dbo.AuditLog; DELETE FROM dbo.AuditLogChainHead; DELETE FROM dbo.AuditLogChainTombstone;"); } private async Task DrainAsync(bool chainBacklog = false) { await using var ctx = CreateContext(); var chain = new AuditLogHashChain(ctx) { AnchorClock = () => Cutoff }; for (var i = 0; i < 1000; i++) { var r = await chain.ChainPendingAsync(500, chainBacklog); if (r.RowsChained == 0 && !r.LockBusy) { return; } } throw new InvalidOperationException("Chain did not drain."); } private async Task VerifyAsync(DateTime month) { await using var ctx = CreateContext(); return await new AuditLogHashChain(ctx).VerifyMonthAsync(month); } private async Task AnchorCutoffAsync() => DateTime.SpecifyKind( await ScalarAsync("SELECT PurgedAtUtc FROM dbo.AuditLogChainTombstone WHERE ChainSeq = 0;"), DateTimeKind.Utc); private async Task SetAnchorAsync(DateTime chainFrom, byte[] hash) { await ExecAsync("DELETE FROM dbo.AuditLogChainTombstone WHERE ChainSeq = 0;"); await ExecAsync( "INSERT INTO dbo.AuditLogChainTombstone (ChainMonth, ChainSeq, RowHash, EventId, PurgedAtUtc) " + $"VALUES ('{AuditChainHasher.MonthOf(chainFrom):yyyy-MM-dd}', 0, 0x{Convert.ToHexString(hash)}, '{AuditChainHasher.CutoffAnchorEventId}', '{chainFrom:yyyy-MM-dd HH:mm:ss}');"); } private async Task SeqOfAsync(Guid eventId) => await ScalarAsync($"SELECT ChainSeq FROM dbo.AuditLog WHERE EventId = '{eventId}';"); 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 ExecAsync(string sql) { await using var conn = new SqlConnection(_fixture.ConnectionString); await conn.OpenAsync(); await using var cmd = conn.CreateCommand(); cmd.CommandText = sql; await cmd.ExecuteNonQueryAsync(); } private async Task ScalarAsync(string sql) { await using var conn = new SqlConnection(_fixture.ConnectionString); await conn.OpenAsync(); await using var cmd = conn.CreateCommand(); cmd.CommandText = sql; var value = await cmd.ExecuteScalarAsync(); return value is DateTime dt && typeof(T) == typeof(DateTime) ? (T)(object)DateTime.SpecifyKind(dt, DateTimeKind.Utc) : (T)value!; } private AuditLogPartitionArchive Archive(ScadaBridgeDbContext ctx) => new(ctx, new AuditLogArchiveOptions { Enabled = true, Directory = _archiveRoot.FullName, RowGroupSize = 1_000, }); private static AuditChainHeadRecord Export(AuditChainHeadSnapshot head) => new( AuditChainHeadRecord.CurrentVersion, AuditChainHasher.PartitionLabel(head.Month), head.LastSeq, AuditChainHeadComparer.ToHex(head.LastHash), head.PurgedThroughSeq, head.PurgedThroughHash is null ? null : AuditChainHeadComparer.ToHex(head.PurgedThroughHash), head.ChainedRows, head.Tombstones, head.UpdatedAtUtc, DateTime.UtcNow, "central-a"); private ScadaBridgeDbContext CreateContext() => new(new DbContextOptionsBuilder().UseSqlServer(_fixture.ConnectionString).Options); private static List Rows(DateTime first, int count, AuditChannel channel = AuditChannel.ApiOutbound) => Enumerable.Range(0, count).Select(i => ScadaBridgeAuditEventFactory.Create( channel: channel, kind: channel == AuditChannel.DbOutbound ? AuditKind.DbWrite : AuditKind.ApiCall, status: AuditStatus.Delivered, occurredAtUtc: first.AddMinutes(i), target: "erp", sourceNode: "central-a", sourceSiteId: "cutoff-test")).ToList(); }