using System.Data.Common; using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ScadaLink.Commons.Entities.Deployment; using ScadaLink.Commons.Entities.Scripts; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Interfaces.Transport; using ScadaLink.Commons.Types.Transport; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; using ScadaLink.ConfigurationDatabase.Services; using ScadaLink.Transport; using ScadaLink.Transport.Import; namespace ScadaLink.Transport.IntegrationTests.Import; /// /// Covers the catch-path invariant in : /// even when the EF RollbackAsync itself throws (connection drop mid- /// rollback, provider bug, etc.) the BundleImportFailed audit row MUST /// still land, and the ORIGINAL exception (not the rollback failure) MUST /// propagate to the caller. /// /// Uses SQLite rather than the in-memory provider because the in-memory /// provider's transaction is a no-op — its RollbackAsync never invokes /// the interceptor, so the throw-on-rollback path can't be exercised. SQLite /// :memory: is keyed per-connection, so the fixture pins a single open /// connection across the whole test. /// /// /// The interceptor is wired to throw on /// and the async equivalent — this is the hook EF invokes synchronously inside /// IDbContextTransaction.RollbackAsync, so a throw there surfaces as /// the RollbackAsync call itself throwing, which is exactly the /// scenario the catch block must survive. /// /// public sealed class BundleImporterRollbackFailureTests : IDisposable { private readonly ServiceProvider _provider; private readonly DbConnection _sharedConnection; private readonly ThrowingRollbackInterceptor _interceptor = new(); public BundleImporterRollbackFailureTests() { var services = new ServiceCollection(); services.AddSingleton( new ConfigurationBuilder().AddInMemoryCollection().Build()); // Pin a single SQLite :memory: connection for the lifetime of the // fixture — :memory: is per-connection so the schema would otherwise // vanish between DbContext instances. _sharedConnection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:"); _sharedConnection.Open(); services.AddSingleton(new EphemeralDataProtectionProvider()); // Register options once under the BASE DbContextOptions key, then // register the subclass as the scoped service used by repositories + // AuditService + BundleImporter. The subclass's ctor accepts // DbContextOptions (the base type's options) so the // single options registration serves both. This avoids the multi-options // pitfall of AddDbContext which keys options on TImpl. services.AddSingleton(sp => { var builder = new DbContextOptionsBuilder(); builder.UseSqlite(_sharedConnection); builder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)); builder.AddInterceptors(_interceptor); return builder.Options; }); services.AddScoped(sp => new SqliteCompatibleScadaLinkDbContext( sp.GetRequiredService>(), sp.GetRequiredService())); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddTransport(); _provider = services.BuildServiceProvider(); // Build schema once on the shared connection. using var scope = _provider.CreateScope(); var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Database.EnsureCreated(); } public void Dispose() { _provider.Dispose(); _sharedConnection.Dispose(); } [Fact] public async Task ApplyAsync_writes_BundleImportFailed_even_when_RollbackAsync_throws() { // Arrange: seed a template whose script body references MissingHelper() // so semantic validation will reject the apply (same broken-bundle shape // as BundleImporterApplyTests.ApplyAsync_rolls_back_all_changes_…). Then // arm the interceptor to throw on rollback so the catch path has to // survive a rollback failure. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("BrokenPump") { Description = "broken" }; t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();")); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(); await WipeContentAsync(); _interceptor.ThrowOnRollback = true; // Act: ApplyAsync must propagate the ORIGINAL exception // (SemanticValidationException) — NOT the InvalidOperationException // that the interceptor raises from inside RollbackAsync. SemanticValidationException? thrown = null; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); thrown = await Assert.ThrowsAsync(() => importer.ApplyAsync(sessionId, new List { new("Template", "BrokenPump", ResolutionAction.Add, null) }, user: "bob")); } Assert.NotNull(thrown); // Assert: even with a rollback failure, the BundleImportFailed audit row // must have landed — that's the whole point of the fix. The row should // also carry the rollback failure's message in its AfterStateJson so // post-mortem readers can see both faults. _interceptor.ThrowOnRollback = false; // let post-condition reads roll back cleanly await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var failed = await ctx.AuditLogEntries .SingleOrDefaultAsync(a => a.Action == "BundleImportFailed"); Assert.NotNull(failed); Assert.Equal("Bundle", failed!.EntityType); // Correlation MUST be null on the failure row — the rolled-back // BundleImportId is intentionally disowned (same contract as the // happy-path rollback test in BundleImporterApplyTests). Assert.Null(failed.BundleImportId); Assert.NotNull(failed.AfterStateJson); // The rollback exception message must be surfaced in the failure // row so operators can see both the cause and the rollback fault. Assert.Contains( ThrowingRollbackInterceptor.RollbackErrorMarker, failed.AfterStateJson!, StringComparison.Ordinal); } } // ---- helpers (copies of the patterns from BundleImporterApplyTests) ---- private async Task ExportAndLoadAsync() { Stream bundleStream; await using (var scope = _provider.CreateAsyncScope()) { var exporter = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync(); var selection = new ExportSelection( TemplateIds: templateIds, SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiKeyIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: false); bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None); } using var ms = new MemoryStream(); await bundleStream.CopyToAsync(ms); ms.Position = 0; await using var loadScope = _provider.CreateAsyncScope(); var importer = loadScope.ServiceProvider.GetRequiredService(); var session = await importer.LoadAsync(ms, passphrase: null); return session.SessionId; } private async Task WipeContentAsync() { await using var scope = _provider.CreateAsyncScope(); var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.RemoveRange(ctx.Templates); ctx.SharedScripts.RemoveRange(ctx.SharedScripts); ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders); await ctx.SaveChangesAsync(); } /// /// EF transaction interceptor that throws on rollback when armed. Used by /// /// to simulate the connection-dropped-during-rollback scenario. EF calls /// the async hook from inside IDbContextTransaction.RollbackAsync, /// so a throw here surfaces as RollbackAsync itself throwing — /// exactly the contract the catch block must survive. /// private sealed class ThrowingRollbackInterceptor : DbTransactionInterceptor { public const string RollbackErrorMarker = "simulated rollback failure"; public bool ThrowOnRollback { get; set; } public override ValueTask TransactionRollingBackAsync( DbTransaction transaction, TransactionEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { if (ThrowOnRollback) { throw new InvalidOperationException(RollbackErrorMarker); } return base.TransactionRollingBackAsync(transaction, eventData, result, cancellationToken); } public override InterceptionResult TransactionRollingBack( DbTransaction transaction, TransactionEventData eventData, InterceptionResult result) { if (ThrowOnRollback) { throw new InvalidOperationException(RollbackErrorMarker); } return base.TransactionRollingBack(transaction, eventData, result); } } } /// /// SQLite-compatible variant of used by /// . Mirrors the adaptations in /// SqliteTestDbContext over in ScadaLink.ConfigurationDatabase.Tests /// (rowversion is nullable, DateTimeOffset stored as ISO 8601 text) but is /// duplicated here to avoid taking a project reference to that test project. /// internal sealed class SqliteCompatibleScadaLinkDbContext : ScadaLinkDbContext { public SqliteCompatibleScadaLinkDbContext( DbContextOptions options, IDataProtectionProvider dataProtectionProvider) : base(options, dataProtectionProvider) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity(builder => { builder.Property(d => d.RowVersion) .IsRequired(false) .IsConcurrencyToken(false) .ValueGeneratedNever(); }); var converter = new ValueConverter( v => v.UtcDateTime.ToString("o"), v => DateTimeOffset.Parse(v)); var nullableConverter = new ValueConverter( v => v.HasValue ? v.Value.UtcDateTime.ToString("o") : null, v => v != null ? DateTimeOffset.Parse(v) : null); foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { foreach (var property in entityType.GetProperties()) { if (property.ClrType == typeof(DateTimeOffset)) { property.SetValueConverter(converter); property.SetColumnType("TEXT"); } else if (property.ClrType == typeof(DateTimeOffset?)) { property.SetValueConverter(nullableConverter); property.SetColumnType("TEXT"); } } } } }