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:
+120
-50
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -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();
|
||||
});
|
||||
|
||||
+6
-3
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user