feat(configdb): map AuditEvent to AuditLog table with PK and five named indexes (#23)

This commit is contained in:
Joseph Doherty
2026-05-20 10:05:49 -04:00
parent 08743bc42d
commit fb423b11ab
3 changed files with 208 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using ScadaLink.Commons.Entities.Audit;
namespace ScadaLink.ConfigurationDatabase.Configurations;
/// <summary>
/// Maps the <see cref="AuditEvent"/> record to the central <c>AuditLog</c> 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.
/// </summary>
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEvent>
{
public void Configure(EntityTypeBuilder<AuditEvent> builder)
{
builder.ToTable("AuditLog");
builder.HasKey(e => e.EventId);
// Enum-as-string columns: bounded varchar(32) ASCII.
builder.Property(e => e.Channel)
.HasConversion<string>()
.HasMaxLength(32)
.IsUnicode(false)
.IsRequired();
builder.Property(e => e.Kind)
.HasConversion<string>()
.HasMaxLength(32)
.IsUnicode(false)
.IsRequired();
builder.Property(e => e.Status)
.HasConversion<string>()
.HasMaxLength(32)
.IsUnicode(false)
.IsRequired();
builder.Property(e => e.ForwardState)
.HasConversion<string>()
.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");
}
}

View File

@@ -84,6 +84,7 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
// Audit // Audit
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>(); public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
public DbSet<AuditEvent> AuditLogs => Set<AuditEvent>();
// Data Protection Keys (for shared ASP.NET Data Protection across nodes) // Data Protection Keys (for shared ASP.NET Data Protection across nodes)
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>(); public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();

View File

@@ -0,0 +1,114 @@
using Microsoft.EntityFrameworkCore;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Configurations;
namespace ScadaLink.ConfigurationDatabase.Tests.Configurations;
/// <summary>
/// Schema-level tests for <see cref="AuditLogEntityTypeConfiguration"/> (#23 M1 Bundle B).
/// Verifies that <see cref="AuditEvent"/> 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.
/// </summary>
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());
}
}