feat(audit)!: ScadaBridge C5 — collapse central dbo.AuditLog to 10 canonical cols + persisted computed cols; CollapseAuditLogToCanonical migration; repo writes canonical directly (Task 2.5)

This commit is contained in:
Joseph Doherty
2026-06-02 14:06:46 -04:00
parent 1737d15f04
commit 68a6bd1720
12 changed files with 2592 additions and 440 deletions
@@ -3,15 +3,20 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Configurations;
/// <summary>
/// Schema-level tests for <see cref="AuditLogEntityTypeConfiguration"/> (#23 M1 Bundle B).
/// Verifies that <see cref="AuditLogRow"/> 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.
/// Schema-level tests for the C5 (Task 2.5) <see cref="AuditLogEntityTypeConfiguration"/>.
/// After the central <c>dbo.AuditLog</c> collapse the table is the 10 canonical
/// <c>ZB.MOM.WW.Audit.AuditEvent</c> columns plus six read-only <b>persisted computed
/// columns</b> derived from <c>DetailsJson</c>. Verifies the PK, the canonical/computed
/// property split, the Channel→Category column mapping, the computed-column SQL, and the
/// named indexes — via EF model metadata on the in-memory SQLite test context (the SQLite
/// test context strips the JSON_VALUE computed SQL so EnsureCreated still succeeds; these
/// metadata assertions read the production-shaped model before that strip is observable
/// for column-name/key/index facts).
/// </summary>
public class AuditLogEntityTypeConfigurationTests : IDisposable
{
@@ -33,7 +38,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
{
// Composite PK {EventId, OccurredAtUtc} is required by the partitioned
// AuditLog table — the clustered key must include the partition column
// (OccurredAtUtc) so each row can be located in its partition (#23 Bundle C).
// (OccurredAtUtc) so each row can be located in its partition.
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
@@ -50,8 +55,8 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
public void Configure_DeclaresUniqueIndex_OnEventIdAlone_ForIdempotencyLookups()
{
// EventId remains globally unique (the idempotency key for
// InsertIfNotExistsAsync, per M1-T8) via a dedicated unique index that
// is independent of the composite PK.
// InsertIfNotExistsAsync) via a dedicated unique index independent of the
// composite PK.
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
@@ -75,10 +80,65 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
.Where(p => !p.IsShadowProperty())
.ToList();
// AuditLogRow record exposes 24 init-only properties (alog.md §4 plus the
// additive ExecutionId universal correlation column, its ParentExecutionId
// sibling, and the SourceNode-stamping column).
Assert.Equal(24, properties.Count);
// C5: 10 canonical columns (EventId, OccurredAtUtc, Actor, Action, Outcome,
// Channel[=Category], Target, SourceNode, CorrelationId, DetailsJson) + 6
// persisted computed columns (Kind, Status, SourceSiteId, ExecutionId,
// ParentExecutionId, IngestedAtUtc) = 16.
Assert.Equal(16, properties.Count);
}
[Fact]
public void Configure_ChannelMapsToCanonicalCategoryColumn()
{
// The Channel enum property is stored in the canonical Category column
// (Category = channel name for ScadaBridge).
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
var channel = entity!.FindProperty(nameof(AuditLogRow.Channel));
Assert.NotNull(channel);
Assert.Equal("Category", channel!.GetColumnName());
}
[Theory]
[InlineData(nameof(AuditLogRow.Kind), "JSON_VALUE(DetailsJson,'$.kind')")]
[InlineData(nameof(AuditLogRow.Status), "JSON_VALUE(DetailsJson,'$.status')")]
[InlineData(nameof(AuditLogRow.SourceSiteId), "JSON_VALUE(DetailsJson,'$.sourceSiteId')")]
[InlineData(nameof(AuditLogRow.ExecutionId), "CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)")]
[InlineData(nameof(AuditLogRow.ParentExecutionId), "CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)")]
public void Configure_ComputedColumns_HaveExpectedPersistedSql(string propertyName, string expectedSql)
{
// Note: the SQLite test context strips computed-column SQL so EnsureCreated
// works. Re-build a SQL Server model here to read the production computed SQL.
using var sqlServerContext = CreateSqlServerModelContext();
var entity = sqlServerContext.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
var property = entity!.FindProperty(propertyName);
Assert.NotNull(property);
Assert.Equal(expectedSql, property!.GetComputedColumnSql());
// Persisted (stored) computed column.
Assert.True(property.GetIsStored());
// Read-only — EF never writes it.
Assert.Equal(Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAddOrUpdate, property.ValueGenerated);
}
[Fact]
public void Configure_IngestedAtUtc_IsNonPersistedComputedColumn()
{
using var sqlServerContext = CreateSqlServerModelContext();
var entity = sqlServerContext.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
var property = entity!.FindProperty(nameof(AuditLogRow.IngestedAtUtc));
Assert.NotNull(property);
Assert.Equal(
"CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))",
property!.GetComputedColumnSql());
// NON-persisted: the datetimeoffset/SWITCHOFFSET cast is non-deterministic, so
// SQL Server rejects a PERSISTED column. It is not indexed, so this is fine.
Assert.False(property.GetIsStored() ?? true);
Assert.Equal(Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAddOrUpdate, property.ValueGenerated);
}
[Fact]
@@ -92,12 +152,8 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
.OrderBy(n => n, StringComparer.Ordinal)
.ToList();
// Five reconciliation/query indexes from alog.md §4, plus the EventId unique
// index introduced alongside the composite PK (Bundle C), plus the additive
// IX_AuditLog_Execution index supporting ExecutionId lookups, the
// IX_AuditLog_ParentExecution index supporting ParentExecutionId lookups,
// and the IX_AuditLog_Node_Occurred composite supporting per-node queries
// (SourceNode-stamping).
// The index NAMES are preserved across the C5 collapse (the column sets move to
// the canonical/computed shape but the discoverable names do not change).
var expected = new[]
{
"IX_AuditLog_Channel_Status_Occurred",
@@ -115,10 +171,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
}
[Theory]
[InlineData(nameof(AuditLogRow.Channel))]
[InlineData(nameof(AuditLogRow.Kind))]
[InlineData(nameof(AuditLogRow.Status))]
[InlineData(nameof(AuditLogRow.ForwardState))]
[InlineData(nameof(AuditLogRow.Channel))]
public void Configure_EnumColumns_StoredAsVarchar32(string propertyName)
{
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
@@ -133,56 +188,58 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.False(property.IsUnicode() ?? true);
}
[Fact]
public void Configure_Outcome_StoredAsVarchar16()
{
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
var property = entity!.FindProperty(nameof(AuditLogRow.Outcome));
Assert.NotNull(property);
Assert.Equal(typeof(string), property!.GetProviderClrType() ?? property.ClrType);
Assert.Equal(16, property.GetMaxLength());
Assert.False(property.IsUnicode() ?? true);
}
[Fact]
public async Task Configure_UtcConverter_HydratesOccurredAtUtcAsKindUtc()
{
// Insert an AuditLogRow with an Unspecified-Kind DateTime, then re-read
// it in a fresh context. The UtcConverter on the OccurredAtUtc /
// IngestedAtUtc columns must re-tag the round-tripped value as
// DateTimeKind.Utc. Without the converter the SQLite (and on production
// SQL Server, datetime2) provider would yield Kind=Unspecified — see
// ConfigurationDatabase-018/020 and Commons-019.
// Insert an AuditLogRow with an Unspecified-Kind DateTime, then re-read it in
// a fresh context. The UtcConverter on OccurredAtUtc must re-tag the
// round-tripped value as DateTimeKind.Utc. Only the canonical columns are
// written here — the computed columns are read-only (and stripped to plain,
// always-null columns on the SQLite test model).
var unspecifiedOccurred = new DateTime(2026, 5, 28, 10, 30, 0, DateTimeKind.Unspecified);
var unspecifiedIngested = new DateTime(2026, 5, 28, 10, 31, 0, DateTimeKind.Unspecified);
var eventId = Guid.NewGuid();
var siteId = "test-" + Guid.NewGuid().ToString("N").Substring(0, 8);
var corr = Guid.NewGuid();
var evt = new AuditLogRow
{
EventId = eventId,
// The AuditLogRow record's init-setter (Commons-019 resolution)
// re-tags Unspecified values as Utc on assignment, so the value EF
// ultimately writes already has Kind=Utc. The converter's job is
// to keep the Kind tag on the READ path, which the assertions
// below exercise.
OccurredAtUtc = unspecifiedOccurred,
IngestedAtUtc = unspecifiedIngested,
Actor = "system",
Action = "ApiOutbound.ApiCall",
Outcome = AuditOutcome.Success,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
SourceSiteId = siteId,
CorrelationId = corr,
DetailsJson = "{\"channel\":\"ApiOutbound\",\"kind\":\"ApiCall\",\"status\":\"Delivered\"}",
};
_context.Set<AuditLogRow>().Add(evt);
await _context.SaveChangesAsync();
// Detach the tracked entity and re-read in a fresh query so we exercise
// the actual hydrate path, not the change-tracker cache.
// Detach and re-read so we exercise the hydrate path, not the change tracker.
_context.ChangeTracker.Clear();
var loaded = await _context.Set<AuditLogRow>()
.AsNoTracking()
.Where(e => e.SourceSiteId == siteId)
.Where(e => e.EventId == eventId)
.SingleAsync();
Assert.Equal(DateTimeKind.Utc, loaded.OccurredAtUtc.Kind);
Assert.NotNull(loaded.IngestedAtUtc);
Assert.Equal(DateTimeKind.Utc, loaded.IngestedAtUtc!.Value.Kind);
// The timestamp ticks must round-trip unchanged — the converter only
// touches the Kind flag, not the wall-clock value.
// The timestamp ticks must round-trip unchanged — the converter only touches
// the Kind flag, not the wall-clock value.
Assert.Equal(unspecifiedOccurred.Ticks, loaded.OccurredAtUtc.Ticks);
Assert.Equal(unspecifiedIngested.Ticks, loaded.IngestedAtUtc.Value.Ticks);
}
[Fact]
@@ -190,8 +247,6 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
{
// Model-metadata cross-check on the converter wiring — guards against a
// future config refactor accidentally removing the HasConversion calls.
// The converter type itself is internal to the configuration, so we
// just assert SOME converter is present on each *Utc DateTime column.
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
Assert.NotNull(entity);
@@ -218,12 +273,27 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Target_Occurred");
Assert.Equal("[Target] IS NOT NULL", targetIdx.GetFilter());
// ExecutionId / ParentExecutionId are computed columns — SQL Server forbids a
// filtered-index predicate on a computed column, so these indexes are UNFILTERED.
var executionIdx = entity.GetIndexes()
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Execution");
Assert.Equal("[ExecutionId] IS NOT NULL", executionIdx.GetFilter());
Assert.Null(executionIdx.GetFilter());
var parentExecutionIdx = entity.GetIndexes()
.Single(i => i.GetDatabaseName() == "IX_AuditLog_ParentExecution");
Assert.Equal("[ParentExecutionId] IS NOT NULL", parentExecutionIdx.GetFilter());
Assert.Null(parentExecutionIdx.GetFilter());
}
/// <summary>
/// Builds a model-only DbContext on the SQL Server provider so the production
/// computed-column SQL (which the SQLite test context strips) can be asserted.
/// No database connection is opened — only the model is materialised.
/// </summary>
private static ScadaBridgeDbContext CreateSqlServerModelContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer("Server=unused;Database=unused;TrustServerCertificate=true")
.Options;
return new ScadaBridgeDbContext(options);
}
}
@@ -211,9 +211,14 @@ public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixtur
// WHERE 1=0 guarantees no rows are touched even if the permission check
// somehow passes — the test asserts the engine rejects the statement
// at permission-check time, not via a side effect on data.
// C5 (Task 2.5): target a CANONICAL (non-computed) column. Status is now a
// persisted computed column, and an UPDATE that sets a computed column is
// rejected with a "cannot be modified" error BEFORE the permission check —
// which would mask the DENY UPDATE this test exercises. Actor is a plain
// writable column, so the permission check is the one that fires.
cmd.CommandText =
$"EXECUTE AS USER = '{testUser}'; " +
$"UPDATE dbo.AuditLog SET Status = 'X' WHERE 1 = 0; " +
$"UPDATE dbo.AuditLog SET Actor = 'X' WHERE 1 = 0; " +
$"REVERT;";
await cmd.ExecuteNonQueryAsync();
});
@@ -133,7 +133,9 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
.ToListAsync();
Assert.Single(loaded);
Assert.Equal("first", loaded[0].ErrorMessage);
// C5 (Task 2.5): ErrorMessage rides in DetailsJson now — decode it to assert
// first-write-wins kept the original payload.
Assert.Equal("first", AuditDetailsCodec.Deserialize(loaded[0].DetailsJson).ErrorMessage);
}
[SkippableFact]
@@ -726,8 +728,9 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Single(rows);
Assert.Equal(preExisting.EventId, rows[0].EventId);
// First-write-wins: the original ErrorMessage (null) survives.
Assert.Null(rows[0].ErrorMessage);
// First-write-wins: the original ErrorMessage (null) survives. C5 (Task 2.5):
// ErrorMessage rides in DetailsJson — decode it to assert.
Assert.Null(AuditDetailsCodec.Deserialize(rows[0].DetailsJson).ErrorMessage);
}
[SkippableFact]
@@ -38,6 +38,12 @@ public class SqliteTestDbContext : ScadaBridgeDbContext
.ValueGeneratedNever();
});
// Note (C5 / Task 2.5): the central dbo.AuditLog persisted computed columns
// (JSON_VALUE-based) are neutralized for non-SQL-Server providers inside
// ScadaBridgeDbContext.OnModelCreating itself, so every SQLite test context —
// including ones that construct ScadaBridgeDbContext directly rather than via
// this helper — gets the strip. Nothing to do here.
// Convert DateTimeOffset to ISO 8601 string for SQLite so ORDER BY works
var converter = new ValueConverter<DateTimeOffset, string>(
v => v.UtcDateTime.ToString("o"),