using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using ScadaLink.Commons.Entities.Deployment; using ScadaLink.Commons.Entities.Instances; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Types.Enums; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; namespace ScadaLink.ConfigurationDatabase.Tests; /// /// A test-specific DbContext that uses an explicit ConcurrencyToken on DeploymentRecord /// (as opposed to SQL Server's IsRowVersion()) so that SQLite can enforce concurrency. /// In production, the SQL Server RowVersion provides this automatically. /// public class ConcurrencyTestDbContext : ScadaLinkDbContext { public ConcurrencyTestDbContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); // Replace the SQL Server RowVersion with an explicit concurrency token for SQLite. // SQLite can't auto-generate rowversion, so disable it and use Status as the token instead. modelBuilder.Entity(builder => { builder.Property(d => d.RowVersion) .IsRequired(false) .IsConcurrencyToken(false) .ValueGeneratedNever(); builder.Property(d => d.Status).IsConcurrencyToken(); }); } } /// /// A SQLite-friendly DbContext that keeps as /// the optimistic-concurrency token but disables auto-generation (SQLite cannot /// auto-populate a rowversion column). The caller sets RowVersion explicitly, which /// is sufficient to exercise the production stub-attach delete path under CD-017's /// concurrency rule. /// public class RowVersionConcurrencyTestDbContext : ScadaLinkDbContext { public RowVersionConcurrencyTestDbContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity(builder => { builder.Property(d => d.RowVersion) .IsRequired(false) .IsConcurrencyToken() .ValueGeneratedNever(); }); } } public class ConcurrencyTests : IDisposable { private readonly string _dbPath; public ConcurrencyTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"scadalink_test_{Guid.NewGuid()}.db"); } public void Dispose() { if (File.Exists(_dbPath)) File.Delete(_dbPath); } private ScadaLinkDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseSqlite($"DataSource={_dbPath}") .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) .Options; return new ConcurrencyTestDbContext(options); } [Fact] public async Task DeploymentRecord_OptimisticConcurrency_SecondUpdateThrows() { // Setup: create necessary entities using (var setupCtx = CreateContext()) { await setupCtx.Database.EnsureCreatedAsync(); var site = new Site("Site1", "S-001"); var template = new Template("T1"); setupCtx.Sites.Add(site); setupCtx.Templates.Add(template); await setupCtx.SaveChangesAsync(); var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id, State = InstanceState.Enabled }; setupCtx.Instances.Add(instance); await setupCtx.SaveChangesAsync(); var record = new DeploymentRecord("deploy-concurrent", "admin") { InstanceId = instance.Id, Status = DeploymentStatus.Pending, DeployedAt = DateTimeOffset.UtcNow }; setupCtx.DeploymentRecords.Add(record); await setupCtx.SaveChangesAsync(); } // Load the same record in two separate contexts using var ctx1 = CreateContext(); using var ctx2 = CreateContext(); var record1 = await ctx1.DeploymentRecords.SingleAsync(d => d.DeploymentId == "deploy-concurrent"); var record2 = await ctx2.DeploymentRecords.SingleAsync(d => d.DeploymentId == "deploy-concurrent"); // Both loaded Status = Pending. First context updates and saves successfully. record1.Status = DeploymentStatus.Success; record1.CompletedAt = DateTimeOffset.UtcNow; await ctx1.SaveChangesAsync(); // Second context tries to update the same record from the stale "Pending" state — should throw // because the Status concurrency token has changed from Pending to Success record2.Status = DeploymentStatus.Failed; await Assert.ThrowsAsync( () => ctx2.SaveChangesAsync()); } [Fact] public async Task Template_NoOptimisticConcurrency_LastWriteWins() { // Setup using (var setupCtx = CreateContext()) { await setupCtx.Database.EnsureCreatedAsync(); var template = new Template("ConcurrentTemplate") { Description = "Original" }; setupCtx.Templates.Add(template); await setupCtx.SaveChangesAsync(); } // Load in two contexts using var ctx1 = CreateContext(); using var ctx2 = CreateContext(); var template1 = await ctx1.Templates.SingleAsync(t => t.Name == "ConcurrentTemplate"); var template2 = await ctx2.Templates.SingleAsync(t => t.Name == "ConcurrentTemplate"); // First update template1.Description = "First update"; await ctx1.SaveChangesAsync(); // Second update — should succeed (last-write-wins, no concurrency token) template2.Description = "Second update"; await ctx2.SaveChangesAsync(); // Should NOT throw // Verify last write won using var verifyCtx = CreateContext(); var loaded = await verifyCtx.Templates.SingleAsync(t => t.Name == "ConcurrentTemplate"); Assert.Equal("Second update", loaded.Description); } [Fact] public async Task DeleteDeploymentRecord_StaleRowVersion_ThrowsConcurrencyException() { // CD-017: Verifies the stub-attach delete path enforces optimistic concurrency // when the caller passes a RowVersion that no longer matches the row's current // RowVersion. Uses a SQLite fixture where DeploymentRecord.RowVersion is an // explicit, caller-managed concurrency token (no SQL Server auto-generation). using var setupCtx = new RowVersionConcurrencyTestDbContext(BuildOptions()); await setupCtx.Database.EnsureCreatedAsync(); var site = new Site("Site1", "S-RV1"); var template = new Template("RV-T1"); setupCtx.Sites.Add(site); setupCtx.Templates.Add(template); await setupCtx.SaveChangesAsync(); var instance = new Instance("RV-I1") { SiteId = site.Id, TemplateId = template.Id, State = InstanceState.Enabled }; setupCtx.Instances.Add(instance); await setupCtx.SaveChangesAsync(); var record = new DeploymentRecord("deploy-rv-stale", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow, RowVersion = new byte[] { 0x01 }, }; setupCtx.DeploymentRecords.Add(record); await setupCtx.SaveChangesAsync(); var id = record.Id; // Reload in a fresh context and simulate a concurrent edit that has advanced // the stored RowVersion. The caller below holds the *prior* RowVersion (0x01) // and is expected to lose the concurrency check. using (var advanceCtx = new RowVersionConcurrencyTestDbContext(BuildOptions())) { var stored = await advanceCtx.DeploymentRecords.SingleAsync(d => d.Id == id); stored.RowVersion = new byte[] { 0x02 }; await advanceCtx.SaveChangesAsync(); } using var deleteCtx = new RowVersionConcurrencyTestDbContext(BuildOptions()); var repository = new DeploymentManagerRepository(deleteCtx); var staleRowVersion = new byte[] { 0x01 }; await repository.DeleteDeploymentRecordAsync(id, staleRowVersion); await Assert.ThrowsAsync( () => repository.SaveChangesAsync()); } private DbContextOptions BuildOptions() { return new DbContextOptionsBuilder() .UseSqlite($"DataSource={_dbPath}") .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) .Options; } [Fact] public void DeploymentRecord_HasRowVersionConfigured() { // Verify the production configuration has a RowVersion shadow property var options = new DbContextOptionsBuilder() .UseSqlite("DataSource=:memory:") .Options; using var context = new ScadaLinkDbContext(options); context.Database.OpenConnection(); context.Database.EnsureCreated(); var entityType = context.Model.FindEntityType(typeof(DeploymentRecord))!; var rowVersion = entityType.FindProperty("RowVersion"); Assert.NotNull(rowVersion); Assert.True(rowVersion!.IsConcurrencyToken); } }