Phase 1 WP-2–10: Repositories, audit service, security & auth (LDAP, JWT, roles, policies, data protection)
- WP-2: SecurityRepository + CentralUiRepository with audit log queries - WP-3: AuditService with transactional guarantee (same SaveChangesAsync) - WP-4: Optimistic concurrency tests (deployment records vs template last-write-wins) - WP-5: Seed data (SCADA-Admins → Admin role mapping) - WP-6: LdapAuthService (direct bind, TLS enforcement, group query) - WP-7: JwtTokenService (HMAC-SHA256, 15-min refresh, 30-min idle timeout) - WP-8: RoleMapper (LDAP groups → roles with site-scoped deployment) - WP-9: Authorization policies (Admin/Design/Deployment + site scope handler) - WP-10: Shared Data Protection keys via EF Core 141 tests pass, zero warnings.
This commit is contained in:
166
tests/ScadaLink.ConfigurationDatabase.Tests/ConcurrencyTests.cs
Normal file
166
tests/ScadaLink.ConfigurationDatabase.Tests/ConcurrencyTests.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
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;
|
||||
|
||||
namespace ScadaLink.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 : ScadaLinkDbContext
|
||||
{
|
||||
public ConcurrencyTestDbContext(DbContextOptions<ScadaLinkDbContext> options) : base(options) { }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Replace the SQL Server RowVersion with an explicit concurrency token for SQLite
|
||||
// Remove the shadow RowVersion property and add a visible ConcurrencyStamp
|
||||
modelBuilder.Entity<DeploymentRecord>(builder =>
|
||||
{
|
||||
// The shadow RowVersion property from the base config doesn't work in SQLite.
|
||||
// Instead, use Status as a concurrency token for the test.
|
||||
builder.Property(d => d.Status).IsConcurrencyToken();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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<ScadaLinkDbContext>()
|
||||
.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 void DeploymentRecord_HasRowVersionConfigured()
|
||||
{
|
||||
// Verify the production configuration has a RowVersion shadow property
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user