Add SiteReplicationActor (runs on every site node) to replicate deployed configs and store-and-forward buffer operations to the standby peer via cluster member discovery and fire-and-forget Tell. Wire ReplicationService handler and pass replication actor to DeploymentManagerActor singleton. Fix 5 pre-existing ConfigurationDatabase test failures: RowVersion NOT NULL on SQLite, stale migration name assertion, and seed data count mismatch.
429 lines
15 KiB
C#
429 lines
15 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ScadaLink.Commons.Entities.Audit;
|
|
using ScadaLink.Commons.Entities.Deployment;
|
|
using ScadaLink.Commons.Entities.ExternalSystems;
|
|
using ScadaLink.Commons.Entities.InboundApi;
|
|
using ScadaLink.Commons.Entities.Instances;
|
|
using ScadaLink.Commons.Entities.Notifications;
|
|
using ScadaLink.Commons.Entities.Scripts;
|
|
using ScadaLink.Commons.Entities.Security;
|
|
using ScadaLink.Commons.Entities.Sites;
|
|
using ScadaLink.Commons.Entities.Templates;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
using ScadaLink.ConfigurationDatabase;
|
|
|
|
namespace ScadaLink.ConfigurationDatabase.Tests;
|
|
|
|
public class DbContextTests : IDisposable
|
|
{
|
|
private readonly ScadaLinkDbContext _context;
|
|
|
|
public DbContextTests()
|
|
{
|
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_context.Database.CloseConnection();
|
|
_context.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public void Schema_CreatesAllTables()
|
|
{
|
|
// Verify all DbSet tables exist by checking we can query them without error
|
|
Assert.NotNull(_context.Templates);
|
|
Assert.NotNull(_context.TemplateAttributes);
|
|
Assert.NotNull(_context.TemplateAlarms);
|
|
Assert.NotNull(_context.TemplateScripts);
|
|
Assert.NotNull(_context.TemplateCompositions);
|
|
Assert.NotNull(_context.Instances);
|
|
Assert.NotNull(_context.InstanceAttributeOverrides);
|
|
Assert.NotNull(_context.InstanceConnectionBindings);
|
|
Assert.NotNull(_context.Areas);
|
|
Assert.NotNull(_context.Sites);
|
|
Assert.NotNull(_context.DataConnections);
|
|
Assert.NotNull(_context.SiteDataConnectionAssignments);
|
|
Assert.NotNull(_context.DeploymentRecords);
|
|
Assert.NotNull(_context.SystemArtifactDeploymentRecords);
|
|
Assert.NotNull(_context.ExternalSystemDefinitions);
|
|
Assert.NotNull(_context.ExternalSystemMethods);
|
|
Assert.NotNull(_context.DatabaseConnectionDefinitions);
|
|
Assert.NotNull(_context.NotificationLists);
|
|
Assert.NotNull(_context.NotificationRecipients);
|
|
Assert.NotNull(_context.SmtpConfigurations);
|
|
Assert.NotNull(_context.SharedScripts);
|
|
Assert.NotNull(_context.LdapGroupMappings);
|
|
Assert.NotNull(_context.SiteScopeRules);
|
|
Assert.NotNull(_context.ApiKeys);
|
|
Assert.NotNull(_context.ApiMethods);
|
|
Assert.NotNull(_context.AuditLogEntries);
|
|
|
|
// Verify we can enumerate all tables (schema is valid)
|
|
Assert.Empty(_context.Templates.ToList());
|
|
Assert.Empty(_context.Sites.ToList());
|
|
Assert.Empty(_context.Instances.ToList());
|
|
Assert.Empty(_context.AuditLogEntries.ToList());
|
|
}
|
|
|
|
[Fact]
|
|
public void Template_WithChildren_CascadeCreated()
|
|
{
|
|
var template = new Template("TestTemplate")
|
|
{
|
|
Description = "A test template"
|
|
};
|
|
template.Attributes.Add(new TemplateAttribute("Attr1") { DataType = DataType.Int32 });
|
|
template.Alarms.Add(new TemplateAlarm("Alarm1") { TriggerType = AlarmTriggerType.ValueMatch, PriorityLevel = 1 });
|
|
template.Scripts.Add(new TemplateScript("Script1", "return 42;"));
|
|
|
|
_context.Templates.Add(template);
|
|
_context.SaveChanges();
|
|
|
|
var loaded = _context.Templates
|
|
.Include(t => t.Attributes)
|
|
.Include(t => t.Alarms)
|
|
.Include(t => t.Scripts)
|
|
.Single(t => t.Name == "TestTemplate");
|
|
|
|
Assert.Single(loaded.Attributes);
|
|
Assert.Single(loaded.Alarms);
|
|
Assert.Single(loaded.Scripts);
|
|
Assert.Equal("Attr1", loaded.Attributes.First().Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void Template_Inheritance_SelfReference()
|
|
{
|
|
var parent = new Template("ParentTemplate");
|
|
_context.Templates.Add(parent);
|
|
_context.SaveChanges();
|
|
|
|
var child = new Template("ChildTemplate") { ParentTemplateId = parent.Id };
|
|
_context.Templates.Add(child);
|
|
_context.SaveChanges();
|
|
|
|
var loaded = _context.Templates.Single(t => t.Name == "ChildTemplate");
|
|
Assert.Equal(parent.Id, loaded.ParentTemplateId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Template_Composition_CreatesRelationship()
|
|
{
|
|
var composedTemplate = new Template("ComposedTemplate");
|
|
var parentTemplate = new Template("ParentTemplate");
|
|
_context.Templates.AddRange(composedTemplate, parentTemplate);
|
|
_context.SaveChanges();
|
|
|
|
parentTemplate.Compositions.Add(new TemplateComposition("Module1") { ComposedTemplateId = composedTemplate.Id });
|
|
_context.SaveChanges();
|
|
|
|
var loaded = _context.Templates
|
|
.Include(t => t.Compositions)
|
|
.Single(t => t.Name == "ParentTemplate");
|
|
|
|
Assert.Single(loaded.Compositions);
|
|
Assert.Equal(composedTemplate.Id, loaded.Compositions.First().ComposedTemplateId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Instance_WithOverridesAndBindings()
|
|
{
|
|
var site = new Site("Site1", "SITE-001");
|
|
var template = new Template("Template1");
|
|
var dataConn = new DataConnection("OpcConn", "OpcUa");
|
|
_context.Sites.Add(site);
|
|
_context.Templates.Add(template);
|
|
_context.DataConnections.Add(dataConn);
|
|
_context.SaveChanges();
|
|
|
|
var instance = new Instance("Instance1")
|
|
{
|
|
TemplateId = template.Id,
|
|
SiteId = site.Id,
|
|
State = InstanceState.Enabled
|
|
};
|
|
instance.AttributeOverrides.Add(new InstanceAttributeOverride("Attr1") { OverrideValue = "42" });
|
|
instance.ConnectionBindings.Add(new InstanceConnectionBinding("TagPath") { DataConnectionId = dataConn.Id });
|
|
_context.Instances.Add(instance);
|
|
_context.SaveChanges();
|
|
|
|
var loaded = _context.Instances
|
|
.Include(i => i.AttributeOverrides)
|
|
.Include(i => i.ConnectionBindings)
|
|
.Single(i => i.UniqueName == "Instance1");
|
|
|
|
Assert.Single(loaded.AttributeOverrides);
|
|
Assert.Single(loaded.ConnectionBindings);
|
|
Assert.Equal(InstanceState.Enabled, loaded.State);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeploymentRecord_CreatesWithAllFields()
|
|
{
|
|
var site = new Site("Site1", "SITE-001");
|
|
var template = new Template("Template1");
|
|
_context.Sites.Add(site);
|
|
_context.Templates.Add(template);
|
|
_context.SaveChanges();
|
|
|
|
var instance = new Instance("Instance1") { TemplateId = template.Id, SiteId = site.Id, State = InstanceState.Enabled };
|
|
_context.Instances.Add(instance);
|
|
_context.SaveChanges();
|
|
|
|
var record = new DeploymentRecord("deploy-001", "admin")
|
|
{
|
|
InstanceId = instance.Id,
|
|
Status = DeploymentStatus.Success,
|
|
RevisionHash = "abc123",
|
|
DeployedAt = DateTimeOffset.UtcNow,
|
|
CompletedAt = DateTimeOffset.UtcNow
|
|
};
|
|
_context.DeploymentRecords.Add(record);
|
|
_context.SaveChanges();
|
|
|
|
var loaded = _context.DeploymentRecords.Single(d => d.DeploymentId == "deploy-001");
|
|
Assert.Equal(DeploymentStatus.Success, loaded.Status);
|
|
Assert.Equal("abc123", loaded.RevisionHash);
|
|
}
|
|
|
|
[Fact]
|
|
public void AuditLogEntry_WritesAndQueries()
|
|
{
|
|
var entry = new AuditLogEntry("admin", "Create", "Template", "1", "TestTemplate")
|
|
{
|
|
Timestamp = DateTimeOffset.UtcNow,
|
|
AfterStateJson = "{\"name\":\"TestTemplate\"}"
|
|
};
|
|
_context.AuditLogEntries.Add(entry);
|
|
_context.SaveChanges();
|
|
|
|
var loaded = _context.AuditLogEntries.Single(a => a.User == "admin");
|
|
Assert.Equal("Create", loaded.Action);
|
|
Assert.Equal("Template", loaded.EntityType);
|
|
Assert.NotNull(loaded.AfterStateJson);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExternalSystem_WithMethods()
|
|
{
|
|
var system = new ExternalSystemDefinition("ERP", "https://erp.example.com/api", "ApiKey")
|
|
{
|
|
MaxRetries = 3,
|
|
RetryDelay = TimeSpan.FromSeconds(5)
|
|
};
|
|
_context.ExternalSystemDefinitions.Add(system);
|
|
_context.SaveChanges();
|
|
|
|
var method = new ExternalSystemMethod("GetOrder", "GET", "/orders/{id}")
|
|
{
|
|
ExternalSystemDefinitionId = system.Id
|
|
};
|
|
_context.ExternalSystemMethods.Add(method);
|
|
_context.SaveChanges();
|
|
|
|
Assert.Single(_context.ExternalSystemMethods.Where(m => m.ExternalSystemDefinitionId == system.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public void NotificationList_WithRecipients()
|
|
{
|
|
var list = new NotificationList("Operators");
|
|
list.Recipients.Add(new NotificationRecipient("John", "john@example.com"));
|
|
list.Recipients.Add(new NotificationRecipient("Jane", "jane@example.com"));
|
|
|
|
_context.NotificationLists.Add(list);
|
|
_context.SaveChanges();
|
|
|
|
var loaded = _context.NotificationLists
|
|
.Include(n => n.Recipients)
|
|
.Single(n => n.Name == "Operators");
|
|
|
|
Assert.Equal(2, loaded.Recipients.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Security_LdapGroupMapping_WithSiteScopeRules()
|
|
{
|
|
var site = new Site("Site1", "SITE-001");
|
|
_context.Sites.Add(site);
|
|
_context.SaveChanges();
|
|
|
|
var mapping = new LdapGroupMapping("CN=Admins,DC=example,DC=com", "Admin");
|
|
_context.LdapGroupMappings.Add(mapping);
|
|
_context.SaveChanges();
|
|
|
|
var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site.Id };
|
|
_context.SiteScopeRules.Add(rule);
|
|
_context.SaveChanges();
|
|
|
|
Assert.Single(_context.SiteScopeRules.Where(r => r.LdapGroupMappingId == mapping.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public void InboundApi_ApiKeyAndMethod()
|
|
{
|
|
var key = new ApiKey("TestKey", "sk-test-123") { IsEnabled = true };
|
|
var method = new ApiMethod("GetStatus", "return \"ok\";") { TimeoutSeconds = 30 };
|
|
|
|
_context.ApiKeys.Add(key);
|
|
_context.ApiMethods.Add(method);
|
|
_context.SaveChanges();
|
|
|
|
Assert.Single(_context.ApiKeys.Where(k => k.Name == "TestKey"));
|
|
Assert.Single(_context.ApiMethods.Where(m => m.Name == "GetStatus"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Area_HierarchyWorks()
|
|
{
|
|
var site = new Site("Site1", "SITE-001");
|
|
_context.Sites.Add(site);
|
|
_context.SaveChanges();
|
|
|
|
var parentArea = new Area("Building A") { SiteId = site.Id };
|
|
_context.Areas.Add(parentArea);
|
|
_context.SaveChanges();
|
|
|
|
var childArea = new Area("Floor 1") { SiteId = site.Id, ParentAreaId = parentArea.Id };
|
|
_context.Areas.Add(childArea);
|
|
_context.SaveChanges();
|
|
|
|
var loaded = _context.Areas
|
|
.Include(a => a.Children)
|
|
.Single(a => a.Name == "Building A");
|
|
|
|
Assert.Single(loaded.Children);
|
|
Assert.Equal("Floor 1", loaded.Children.First().Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void SiteDataConnectionAssignment_CreatesBothForeignKeys()
|
|
{
|
|
var site = new Site("Site1", "SITE-001");
|
|
var conn = new DataConnection("OpcConn", "OpcUa");
|
|
_context.Sites.Add(site);
|
|
_context.DataConnections.Add(conn);
|
|
_context.SaveChanges();
|
|
|
|
var assignment = new SiteDataConnectionAssignment { SiteId = site.Id, DataConnectionId = conn.Id };
|
|
_context.SiteDataConnectionAssignments.Add(assignment);
|
|
_context.SaveChanges();
|
|
|
|
Assert.Single(_context.SiteDataConnectionAssignments.ToList());
|
|
}
|
|
|
|
[Fact]
|
|
public void EnumProperties_StoredAsStrings()
|
|
{
|
|
var template = new Template("EnumTest");
|
|
template.Attributes.Add(new TemplateAttribute("Attr1") { DataType = DataType.Double });
|
|
template.Alarms.Add(new TemplateAlarm("Alarm1") { TriggerType = AlarmTriggerType.RangeViolation, PriorityLevel = 1 });
|
|
_context.Templates.Add(template);
|
|
_context.SaveChanges();
|
|
|
|
// Query using raw SQL to verify string storage
|
|
var attr = _context.TemplateAttributes.Single(a => a.Name == "Attr1");
|
|
Assert.Equal(DataType.Double, attr.DataType);
|
|
|
|
var alarm = _context.TemplateAlarms.Single(a => a.Name == "Alarm1");
|
|
Assert.Equal(AlarmTriggerType.RangeViolation, alarm.TriggerType);
|
|
}
|
|
|
|
[Fact]
|
|
public void UniqueConstraint_Template_Name_Enforced()
|
|
{
|
|
_context.Templates.Add(new Template("Unique"));
|
|
_context.SaveChanges();
|
|
|
|
_context.Templates.Add(new Template("Unique"));
|
|
Assert.ThrowsAny<Exception>(() => _context.SaveChanges());
|
|
}
|
|
|
|
[Fact]
|
|
public void DateTimeOffset_MappedCorrectly()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var entry = new AuditLogEntry("user", "Test", "Entity", "1", "Name") { Timestamp = now };
|
|
_context.AuditLogEntries.Add(entry);
|
|
_context.SaveChanges();
|
|
|
|
_context.ChangeTracker.Clear();
|
|
var loaded = _context.AuditLogEntries.Single();
|
|
// SQLite has limited DateTimeOffset precision, but the round-trip should preserve the value within a second
|
|
Assert.True(Math.Abs((loaded.Timestamp - now).TotalSeconds) < 1);
|
|
}
|
|
}
|
|
|
|
public class ServiceRegistrationTests
|
|
{
|
|
[Fact]
|
|
public void AddConfigurationDatabase_WithConnectionString_RegistersDbContext()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddConfigurationDatabase("DataSource=:memory:");
|
|
|
|
var provider = services.BuildServiceProvider();
|
|
var context = provider.GetService<ScadaLinkDbContext>();
|
|
|
|
Assert.NotNull(context);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddConfigurationDatabase_NoArgs_DoesNotThrow()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddConfigurationDatabase();
|
|
|
|
// Should not register DbContext (no-op for backward compatibility)
|
|
var provider = services.BuildServiceProvider();
|
|
var context = provider.GetService<ScadaLinkDbContext>();
|
|
Assert.Null(context);
|
|
}
|
|
}
|
|
|
|
public class MigrationHelperTests : IDisposable
|
|
{
|
|
private readonly ScadaLinkDbContext _context;
|
|
|
|
public MigrationHelperTests()
|
|
{
|
|
// Use SQLite with PendingModelChangesWarning suppressed because the migration
|
|
// was generated for SQL Server and SQLite's model representation differs slightly.
|
|
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
|
.UseSqlite("DataSource=:memory:")
|
|
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))
|
|
.Options;
|
|
|
|
_context = new ScadaLinkDbContext(options);
|
|
_context.Database.OpenConnection();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_context.Database.CloseConnection();
|
|
_context.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyOrValidate_ProductionMode_WithPendingMigrations_Throws()
|
|
{
|
|
// Database has no schema yet, so pending migrations exist.
|
|
// The production path uses GetPendingMigrationsAsync which works cross-provider.
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => MigrationHelper.ApplyOrValidateMigrationsAsync(_context, isDevelopment: false));
|
|
|
|
Assert.Contains("pending migration", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void MigrationExists_InitialCreate()
|
|
{
|
|
// Verify the InitialCreate migration is detected as pending
|
|
var pending = _context.Database.GetPendingMigrations().ToList();
|
|
Assert.Contains(pending, m => m.Contains("InitialSchema"));
|
|
}
|
|
}
|