using Microsoft.EntityFrameworkCore; using ScadaLink.Commons.Entities.ExternalSystems; using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Entities.Templates; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; namespace ScadaLink.ConfigurationDatabase.Tests; public class SchemaConfigurationTests : IDisposable { private readonly ScadaLinkDbContext _context; public SchemaConfigurationTests() { _context = SqliteTestHelper.CreateInMemoryContext(); } public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } // ConfigurationDatabase-006: the gRPC node-address columns must be length-bounded // (HasMaxLength(500)) consistently with the sibling NodeAAddress/NodeBAddress columns, // rather than being left to map to nvarchar(max). [Theory] [InlineData(nameof(Site.GrpcNodeAAddress))] [InlineData(nameof(Site.GrpcNodeBAddress))] public void GrpcNodeAddressColumns_AreLengthBoundedTo500(string propertyName) { var property = _context.Model .FindEntityType(typeof(Site))! .FindProperty(propertyName)!; Assert.Equal(500, property.GetMaxLength()); } [Theory] [InlineData(nameof(Site.NodeAAddress))] [InlineData(nameof(Site.NodeBAddress))] public void GrpcNodeAddressColumns_MatchSiblingNodeAddressBounds(string siblingPropertyName) { var entity = _context.Model.FindEntityType(typeof(Site))!; var siblingMaxLength = entity.FindProperty(siblingPropertyName)!.GetMaxLength(); Assert.Equal(siblingMaxLength, entity.FindProperty(nameof(Site.GrpcNodeAAddress))!.GetMaxLength()); Assert.Equal(siblingMaxLength, entity.FindProperty(nameof(Site.GrpcNodeBAddress))!.GetMaxLength()); } // ConfigurationDatabase-014: the encrypting value converter must be applied // uniformly to all three secret-bearing columns, including the non-nullable // DatabaseConnectionDefinition.ConnectionString. A regression here (e.g. the // converter dropped from one HasConversion call) would silently store a secret // in plaintext. [Theory] [InlineData(typeof(SmtpConfiguration), nameof(SmtpConfiguration.Credentials))] [InlineData(typeof(ExternalSystemDefinition), nameof(ExternalSystemDefinition.AuthConfiguration))] [InlineData(typeof(DatabaseConnectionDefinition), nameof(DatabaseConnectionDefinition.ConnectionString))] public void SecretColumns_AllHaveEncryptedStringConverterApplied(Type entityType, string propertyName) { var converter = _context.Model .FindEntityType(entityType)! .FindProperty(propertyName)! .GetValueConverter(); Assert.IsType(converter); } } public class SplitQueryBehaviourTests : IDisposable { private readonly ScadaLinkDbContext _context; private readonly TemplateEngineRepository _repository; public SplitQueryBehaviourTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new TemplateEngineRepository(_context); } public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } // ConfigurationDatabase-009: the multi-collection eager-load queries were switched to // AsSplitQuery() to avoid cartesian-product joins. The result set must be unchanged — // every member collection still fully populated, with no row duplication. [Fact] public async Task GetAllTemplatesAsync_WithMultipleMembersPerCollection_LoadsAllWithoutDuplication() { var template = new Template("MultiMember"); for (int i = 0; i < 3; i++) template.Attributes.Add(new TemplateAttribute($"Attr{i}")); for (int i = 0; i < 2; i++) template.Alarms.Add(new TemplateAlarm($"Alarm{i}")); for (int i = 0; i < 4; i++) template.Scripts.Add(new TemplateScript($"Script{i}", "return 1;")); _context.Templates.Add(template); await _context.SaveChangesAsync(); _context.ChangeTracker.Clear(); var all = await _repository.GetAllTemplatesAsync(); var loaded = Assert.Single(all); // A cartesian-product single query would yield 3 x 2 x 4 = 24 joined rows; the // collections must still contain exactly the inserted counts. Assert.Equal(3, loaded.Attributes.Count); Assert.Equal(2, loaded.Alarms.Count); Assert.Equal(4, loaded.Scripts.Count); } [Fact] public async Task GetTemplateByIdAsync_WithMultipleMembers_LoadsAllCollections() { var template = new Template("Single"); template.Attributes.Add(new TemplateAttribute("A1")); template.Attributes.Add(new TemplateAttribute("A2")); template.Scripts.Add(new TemplateScript("S1", "return 1;")); _context.Templates.Add(template); await _context.SaveChangesAsync(); _context.ChangeTracker.Clear(); var loaded = await _repository.GetTemplateByIdAsync(template.Id); Assert.NotNull(loaded); Assert.Equal(2, loaded!.Attributes.Count); Assert.Single(loaded.Scripts); } // ConfigurationDatabase-012: the ApiKey table must persist the bearer credential // as a hash column (KeyHash) and must NOT carry a plaintext KeyValue column. [Fact] public void ApiKey_KeyHashColumn_IsMappedAndUniquelyIndexed() { var entityType = _context.Model.FindEntityType(typeof(ScadaLink.Commons.Entities.InboundApi.ApiKey))!; var keyHash = entityType.FindProperty("KeyHash"); Assert.NotNull(keyHash); Assert.False(keyHash!.IsNullable); var hashIndex = entityType.GetIndexes() .FirstOrDefault(i => i.Properties.Any(p => p.Name == "KeyHash")); Assert.NotNull(hashIndex); Assert.True(hashIndex!.IsUnique); } [Fact] public void ApiKey_HasNoPlaintextKeyValueColumn() { var entityType = _context.Model.FindEntityType(typeof(ScadaLink.Commons.Entities.InboundApi.ApiKey))!; Assert.Null(entityType.FindProperty("KeyValue")); } }