From fb423b11abfe4a14aab67580eeaa5da3f55a714b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 10:05:49 -0400 Subject: [PATCH] feat(configdb): map AuditEvent to AuditLog table with PK and five named indexes (#23) --- .../AuditLogEntityTypeConfiguration.cs | 93 ++++++++++++++ .../ScadaLinkDbContext.cs | 1 + .../AuditLogEntityTypeConfigurationTests.cs | 114 ++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs create mode 100644 tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs new file mode 100644 index 0000000..ca914d3 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using ScadaLink.Commons.Entities.Audit; + +namespace ScadaLink.ConfigurationDatabase.Configurations; + +/// +/// Maps the record to the central AuditLog table +/// described in alog.md §4. Column lengths/types and the five named indexes are +/// fixed by that specification — keep this in sync with the doc. +/// +public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("AuditLog"); + + builder.HasKey(e => e.EventId); + + // Enum-as-string columns: bounded varchar(32) ASCII. + builder.Property(e => e.Channel) + .HasConversion() + .HasMaxLength(32) + .IsUnicode(false) + .IsRequired(); + + builder.Property(e => e.Kind) + .HasConversion() + .HasMaxLength(32) + .IsUnicode(false) + .IsRequired(); + + builder.Property(e => e.Status) + .HasConversion() + .HasMaxLength(32) + .IsUnicode(false) + .IsRequired(); + + builder.Property(e => e.ForwardState) + .HasConversion() + .HasMaxLength(32) + .IsUnicode(false); + + // Ascii identifier columns — never carry user-supplied unicode. + builder.Property(e => e.SourceSiteId) + .HasMaxLength(64) + .IsUnicode(false); + + builder.Property(e => e.SourceInstanceId) + .HasMaxLength(128) + .IsUnicode(false); + + builder.Property(e => e.SourceScript) + .HasMaxLength(128) + .IsUnicode(false); + + builder.Property(e => e.Actor) + .HasMaxLength(128) + .IsUnicode(false); + + builder.Property(e => e.Target) + .HasMaxLength(256) + .IsUnicode(false); + + // Bounded unicode message column. + builder.Property(e => e.ErrorMessage) + .HasMaxLength(1024); + + // ErrorDetail, RequestSummary, ResponseSummary, Extra: leave as nvarchar(max). + + // Indexes — names locked to alog.md §4 for reconciliation/migration discoverability. + builder.HasIndex(e => e.OccurredAtUtc) + .IsDescending(true) + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + builder.HasIndex(e => new { e.SourceSiteId, e.OccurredAtUtc }) + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + builder.HasIndex(e => e.CorrelationId) + .HasFilter("[CorrelationId] IS NOT NULL") + .HasDatabaseName("IX_AuditLog_CorrelationId"); + + builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc }) + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + builder.HasIndex(e => new { e.Target, e.OccurredAtUtc }) + .IsDescending(false, true) + .HasFilter("[Target] IS NOT NULL") + .HasDatabaseName("IX_AuditLog_Target_Occurred"); + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs index c1db4a7..f25118f 100644 --- a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs +++ b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs @@ -84,6 +84,7 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext // Audit public DbSet AuditLogEntries => Set(); + public DbSet AuditLogs => Set(); // Data Protection Keys (for shared ASP.NET Data Protection across nodes) public DbSet DataProtectionKeys => Set(); diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs new file mode 100644 index 0000000..b70ee0f --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs @@ -0,0 +1,114 @@ +using Microsoft.EntityFrameworkCore; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Configurations; + +namespace ScadaLink.ConfigurationDatabase.Tests.Configurations; + +/// +/// Schema-level tests for (#23 M1 Bundle B). +/// Verifies that maps to the AuditLog table with the +/// PK, property set, column types/lengths, and five named indexes specified in alog.md §4. +/// Inspects EF model metadata via the existing in-memory SQLite test context — no +/// database round-trips required. +/// +public class AuditLogEntityTypeConfigurationTests : IDisposable +{ + private readonly ScadaLinkDbContext _context; + + public AuditLogEntityTypeConfigurationTests() + { + _context = SqliteTestHelper.CreateInMemoryContext(); + } + + public void Dispose() + { + _context.Database.CloseConnection(); + _context.Dispose(); + } + + [Fact] + public void Configure_MapsToAuditLogTable_WithEventIdAsPrimaryKey() + { + var entity = _context.Model.FindEntityType(typeof(AuditEvent)); + + Assert.NotNull(entity); + Assert.Equal("AuditLog", entity!.GetTableName()); + + var pk = entity.FindPrimaryKey(); + Assert.NotNull(pk); + var pkProperty = Assert.Single(pk!.Properties); + Assert.Equal(nameof(AuditEvent.EventId), pkProperty.Name); + } + + [Fact] + public void Configure_HasExpectedPropertyCount() + { + var entity = _context.Model.FindEntityType(typeof(AuditEvent)); + Assert.NotNull(entity); + + var properties = entity!.GetProperties() + .Where(p => !p.IsShadowProperty()) + .ToList(); + + // AuditEvent record exposes 21 init-only properties (alog.md §4). + Assert.Equal(21, properties.Count); + } + + [Fact] + public void Configure_FiveExpectedIndexes_WithCorrectNames() + { + var entity = _context.Model.FindEntityType(typeof(AuditEvent)); + Assert.NotNull(entity); + + var indexNames = entity!.GetIndexes() + .Select(i => i.GetDatabaseName()) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + + var expected = new[] + { + "IX_AuditLog_Channel_Status_Occurred", + "IX_AuditLog_CorrelationId", + "IX_AuditLog_OccurredAtUtc", + "IX_AuditLog_Site_Occurred", + "IX_AuditLog_Target_Occurred", + }; + + Assert.Equal(expected, indexNames); + } + + [Theory] + [InlineData(nameof(AuditEvent.Channel))] + [InlineData(nameof(AuditEvent.Kind))] + [InlineData(nameof(AuditEvent.Status))] + [InlineData(nameof(AuditEvent.ForwardState))] + public void Configure_EnumColumns_StoredAsVarchar32(string propertyName) + { + var entity = _context.Model.FindEntityType(typeof(AuditEvent)); + Assert.NotNull(entity); + + var property = entity!.FindProperty(propertyName); + Assert.NotNull(property); + + // Enums are converted to strings (varchar(32) IsUnicode=false on SQL Server). + Assert.Equal(typeof(string), property!.GetProviderClrType() ?? property.ClrType); + Assert.Equal(32, property.GetMaxLength()); + Assert.False(property.IsUnicode() ?? true); + } + + [Fact] + public void Configure_FilteredIndexes_HaveExpectedFilters() + { + var entity = _context.Model.FindEntityType(typeof(AuditEvent)); + Assert.NotNull(entity); + + var correlationIdx = entity!.GetIndexes() + .Single(i => i.GetDatabaseName() == "IX_AuditLog_CorrelationId"); + Assert.Equal("[CorrelationId] IS NOT NULL", correlationIdx.GetFilter()); + + var targetIdx = entity.GetIndexes() + .Single(i => i.GetDatabaseName() == "IX_AuditLog_Target_Occurred"); + Assert.Equal("[Target] IS NOT NULL", targetIdx.GetFilter()); + } +}