7b0b9c7365
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
253 lines
9.7 KiB
C#
253 lines
9.7 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class ConcurrencyTestDbContext : ScadaBridgeDbContext
|
|
{
|
|
public ConcurrencyTestDbContext(DbContextOptions<ScadaBridgeDbContext> 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<DeploymentRecord>(builder =>
|
|
{
|
|
builder.Property(d => d.RowVersion)
|
|
.IsRequired(false)
|
|
.IsConcurrencyToken(false)
|
|
.ValueGeneratedNever();
|
|
|
|
builder.Property(d => d.Status).IsConcurrencyToken();
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A SQLite-friendly DbContext that keeps <see cref="DeploymentRecord.RowVersion"/> 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.
|
|
/// </summary>
|
|
public class RowVersionConcurrencyTestDbContext : ScadaBridgeDbContext
|
|
{
|
|
public RowVersionConcurrencyTestDbContext(DbContextOptions<ScadaBridgeDbContext> options) : base(options) { }
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
base.OnModelCreating(modelBuilder);
|
|
|
|
modelBuilder.Entity<DeploymentRecord>(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(), $"scadabridge_test_{Guid.NewGuid()}.db");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (File.Exists(_dbPath))
|
|
File.Delete(_dbPath);
|
|
}
|
|
|
|
private ScadaBridgeDbContext CreateContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
|
.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<DbUpdateConcurrencyException>(
|
|
() => 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<DbUpdateConcurrencyException>(
|
|
() => repository.SaveChangesAsync());
|
|
}
|
|
|
|
private DbContextOptions<ScadaBridgeDbContext> BuildOptions()
|
|
{
|
|
return new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
|
.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<ScadaBridgeDbContext>()
|
|
.UseSqlite("DataSource=:memory:")
|
|
.Options;
|
|
|
|
using var context = new ScadaBridgeDbContext(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);
|
|
}
|
|
}
|