using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ZB.MOM.WW.Audit; 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.Maintenance; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance; /// /// The hardened two-login setup (ScadaBridge#107 item 3), end to end on SQL Server: a writer /// login (db_datareader + db_datawriter + scadabridge_audit_writer) and a /// maintenance login (scadabridge_audit_chainer + scadabridge_audit_purger only), /// wired through AddConfigurationDatabase(writer, maintenance). The writer ingests but /// cannot chain, update or delete; the maintenance services run on the maintenance login and /// can chain, verify, export heads, per-channel purge, enumerate partitions, archive and switch. /// public sealed class AuditMaintenanceLoginTests : IClassFixture, IAsyncLifetime { private const string Password = "Sb_W22_maint#2026"; private readonly MsSqlMigrationFixture _fixture; private readonly string _suffix = Guid.NewGuid().ToString("N")[..10]; private readonly DirectoryInfo _archiveDir = Directory.CreateTempSubdirectory("maint-login-archive-"); /// Creates the test class with its own migrated SQL Server database. /// The per-class migration fixture. public AuditMaintenanceLoginTests(MsSqlMigrationFixture fixture) => _fixture = fixture; private string WriterLogin => "sbw_" + _suffix; private string MaintenanceLogin => "sbm_" + _suffix; /// public async Task InitializeAsync() { if (!_fixture.Available) { return; } await using var conn = _fixture.OpenConnection(); await using var cmd = new SqlCommand($@" CREATE LOGIN [{WriterLogin}] WITH PASSWORD = N'{Password}', CHECK_POLICY = OFF; CREATE LOGIN [{MaintenanceLogin}] WITH PASSWORD = N'{Password}', CHECK_POLICY = OFF; CREATE USER [{WriterLogin}] FOR LOGIN [{WriterLogin}]; CREATE USER [{MaintenanceLogin}] FOR LOGIN [{MaintenanceLogin}]; ALTER ROLE db_datareader ADD MEMBER [{WriterLogin}]; ALTER ROLE db_datawriter ADD MEMBER [{WriterLogin}]; ALTER ROLE scadabridge_audit_writer ADD MEMBER [{WriterLogin}]; ALTER ROLE scadabridge_audit_chainer ADD MEMBER [{MaintenanceLogin}]; ALTER ROLE scadabridge_audit_purger ADD MEMBER [{MaintenanceLogin}];", conn); await cmd.ExecuteNonQueryAsync(); } /// public async Task DisposeAsync() { _archiveDir.Delete(recursive: true); if (!_fixture.Available) { return; } SqlConnection.ClearAllPools(); await using var conn = _fixture.OpenConnection(); await using var cmd = new SqlCommand($@" 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 IN (N'{WriterLogin}', N'{MaintenanceLogin}'); EXEC sp_executesql @kill; IF USER_ID(N'{WriterLogin}') IS NOT NULL DROP USER [{WriterLogin}]; IF USER_ID(N'{MaintenanceLogin}') IS NOT NULL DROP USER [{MaintenanceLogin}]; IF SUSER_ID(N'{WriterLogin}') IS NOT NULL DROP LOGIN [{WriterLogin}]; IF SUSER_ID(N'{MaintenanceLogin}') IS NOT NULL DROP LOGIN [{MaintenanceLogin}];", conn); await cmd.ExecuteNonQueryAsync(); } /// /// Writer ingests and is refused every maintenance write; the maintenance services, resolved /// from DI on the separate login, do the whole chain + purge + archive cycle. /// /// A task that represents the asynchronous operation. [SkippableFact] public async Task HardenedTwoLoginSetup_WriterIngestsOnly_MaintenanceLoginRunsChainAndPurges() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var writerCs = As(WriterLogin); var maintenanceCs = As(MaintenanceLogin); var month = new DateTime(2027, 2, 1, 0, 0, 0, DateTimeKind.Utc); // The writer login ingests. await using (var writerCtx = Context(writerCs)) { var repo = new AuditLogRepository(writerCtx); foreach (var i in Enumerable.Range(1, 4)) { await repo.InsertIfNotExistsAsync(NewEvent(month.AddDays(i), AuditChannel.ApiOutbound)); } await repo.InsertIfNotExistsAsync(NewEvent(month.AddHours(2), AuditChannel.DbOutbound)); } // Negative controls: the writer login can neither chain nor delete. await using (var writerCtx = Context(writerCs)) { await Assert.ThrowsAsync(() => new AuditLogHashChain(writerCtx).ChainPendingAsync(100)); await Assert.ThrowsAsync(() => new AuditLogRepository(writerCtx).PurgeChannelOlderThanAsync("DbOutbound", month.AddDays(1), 100)); } var services = new ServiceCollection(); services.AddLogging(); services.AddConfigurationDatabase(writerCs, maintenanceCs); services.AddSingleton(Options.Create(new AuditLogArchiveOptions { Enabled = true, Directory = _archiveDir.FullName, RowGroupSize = 1_000, })); await using var provider = services.BuildServiceProvider(); await using (var scope = provider.CreateAsyncScope()) { var maintenance = scope.ServiceProvider.GetRequiredService(); Assert.True(maintenance.UsesSeparateLogin); Assert.Equal(MaintenanceLogin, await LoginNameAsync(maintenance.Context)); Assert.Equal(WriterLogin, await LoginNameAsync(scope.ServiceProvider.GetRequiredService())); var chain = scope.ServiceProvider.GetRequiredService(); Assert.Equal(5, (await chain.ChainPendingAsync(100)).RowsChained); Assert.Equal(AuditChainStatus.Intact, (await chain.VerifyMonthAsync(month)).Status); var head = Assert.Single(await scope.ServiceProvider.GetRequiredService().ReadHeadsAsync()); Assert.Equal(5, head.LastSeq); var repo = scope.ServiceProvider.GetRequiredKeyedService(AuditMaintenanceContext.ServiceKey); Assert.Equal(1, await repo.PurgeChannelOlderThanAsync("DbOutbound", month.AddDays(1), 100)); Assert.Contains(month, await repo.GetPartitionBoundariesOlderThanAsync(month.AddYears(5))); var archive = scope.ServiceProvider.GetRequiredKeyedService(AuditMaintenanceContext.ServiceKey); var manifest = await archive.ArchivePartitionAsync(month); Assert.Equal(4, manifest.RowCount); Assert.Equal(4, await archive.PurgeArchivedPartitionAsync(manifest)); Assert.Equal(AuditChainStatus.Intact, (await chain.VerifyMonthAsync(month)).Status); } // Unset maintenance string: the maintenance context is the runtime context, as before. var plain = new ServiceCollection(); plain.AddLogging(); plain.AddConfigurationDatabase(writerCs); await using var plainProvider = plain.BuildServiceProvider(); await using var plainScope = plainProvider.CreateAsyncScope(); var shared = plainScope.ServiceProvider.GetRequiredService(); Assert.False(shared.UsesSeparateLogin); Assert.Same(plainScope.ServiceProvider.GetRequiredService(), shared.Context); } /// /// ScadaBridge#113: the monthly roll-forward (ALTER PARTITION FUNCTION … SPLIT RANGE) and /// the SourceNode backfill run on the maintenance login — and are refused to the writer /// login — with only the role grants the AuditMaintenanceGrants migration provisions. /// /// A task that represents the asynchronous operation. [SkippableFact] public async Task RollForwardAndSourceNodeBackfill_RunOnTheMaintenanceLogin_NotTheWriter() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var writerCs = As(WriterLogin); var maintenanceCs = As(MaintenanceLogin); var month = new DateTime(2027, 4, 1, 0, 0, 0, DateTimeKind.Utc); // A pre-feature row: no SourceNode, not chained. await using (var writerCtx = Context(writerCs)) { var legacy = NewEvent(month.AddDays(1), AuditChannel.ApiOutbound) with { SourceNode = null }; await new AuditLogRepository(writerCtx).InsertIfNotExistsAsync(legacy); } // Negative controls: the writer login can neither split the partition function nor backfill. await using (var writerCtx = Context(writerCs)) { await Assert.ThrowsAsync(() => new AuditLogPartitionMaintenance(writerCtx).EnsureLookaheadAsync(60)); await Assert.ThrowsAsync(() => new AuditLogRepository(writerCtx).BackfillSourceNodeAsync("unknown", month.AddYears(1), 100)); } var services = new ServiceCollection(); services.AddLogging(); services.AddConfigurationDatabase(writerCs, maintenanceCs); await using var provider = services.BuildServiceProvider(); await using (var scope = provider.CreateAsyncScope()) { var maintenance = scope.ServiceProvider.GetRequiredService(); var roll = scope.ServiceProvider.GetRequiredService(); var before = await roll.GetMaxBoundaryAsync(); var added = await roll.EnsureLookaheadAsync(60); Assert.NotEmpty(added); Assert.True(await roll.GetMaxBoundaryAsync() > before); var repo = scope.ServiceProvider.GetRequiredKeyedService( ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.AuditLogServiceKeys.Maintenance); Assert.Equal(1, await repo.BackfillSourceNodeAsync("unknown", month.AddYears(1), 100)); Assert.Equal(MaintenanceLogin, await LoginNameAsync(maintenance.Context)); } } private string As(string login) => new SqlConnectionStringBuilder(_fixture.ConnectionString) { UserID = login, Password = Password, IntegratedSecurity = false, }.ConnectionString; private static ScadaBridgeDbContext Context(string cs) => new(new DbContextOptionsBuilder().UseSqlServer(cs).Options); private static async Task LoginNameAsync(ScadaBridgeDbContext ctx) { var conn = ctx.Database.GetDbConnection(); await conn.OpenAsync(); try { await using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT SUSER_SNAME();"; return (string)(await cmd.ExecuteScalarAsync())!; } finally { await conn.CloseAsync(); } } private static AuditEvent NewEvent(DateTime occurredAtUtc, AuditChannel channel) => ScadaBridgeAuditEventFactory.Create( channel: channel, kind: channel == AuditChannel.DbOutbound ? AuditKind.DbWrite : AuditKind.ApiCall, status: AuditStatus.Delivered, occurredAtUtc: occurredAtUtc, actor: "maint-login-test", target: "erp", sourceNode: "central-a", sourceSiteId: "maint-login-test", correlationId: Guid.NewGuid()); }