chore(audit): ScadaBridge C7 — perf re-baseline + CollapseAuditLogToCanonical projection test + index-test fix + dead-cref cleanup (Task 2.5)
Perf re-baseline (HotPathLatencyTests): empirical p95 on Apple M-series Release build: 4KB DetailsJson slow path ≈14 µs, small-DetailsJson no-redactors ≈2 µs, true no-op fast path ≈0 µs. Thresholds updated: 200 µs / 30 µs / 5 µs (≈15× headroom for contested CI runners). Old thresholds (50 µs / 10 µs) were set for the pre-C3 typed-field path; canonical JSON parse+rewrite is empirically faster. Adds a third test (Filter_Apply_NoDetailsJson_FastPath) that asserts same-instance return on the DetailsJson-null + within-cap fast path. Env-var overrides retained. CollapseAuditLogToCanonicalMigrationTests (new): three MSSQL-gated [SkippableFact] tests verifying Action/Category/Outcome projection, NULL Actor, DetailsJson codec round-trip, and all six persisted computed columns (Kind/Status/SourceSiteId/ ExecutionId/ParentExecutionId) for ApiOutbound, InboundAuthFailure, and Failed- status rows. AddAuditLogTableMigrationTests: rename CreatesFiveNamedIndexes → CreatesNineNamedIndexes; expand coverage from 5 original indexes to all 9 named non-clustered indexes present after CollapseAuditLogToCanonical (adds IX_AuditLog_Execution, IX_AuditLog_ParentExecution, IX_AuditLog_Node_Occurred, UX_AuditLog_EventId). Dead-cref cleanup: zero references to the deleted IAuditPayloadFilter / DefaultAuditPayloadFilter / SafeDefaultAuditPayloadFilter types remain in any .cs file (source or test). 26 occurrences across 13 files replaced with correct references to IAuditRedactor / ScadaBridgeAuditRedactor / SafeDefaultAuditRedactor or reworded as plain prose. Residual sweep: no unused transitional code found beyond the acknowledged "C3 transitional shim" comments on IngestedAtUtc stamping (active code, not dead).
This commit is contained in:
+19
-1
@@ -84,18 +84,36 @@ public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixtur
|
||||
Assert.Equal("ps_AuditLog_Month", schemeName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies all nine named non-clustered indexes exist on the final
|
||||
/// <c>dbo.AuditLog</c> table after all migrations have been applied.
|
||||
/// The original five indexes were created by <c>AddAuditLogTable</c>;
|
||||
/// the <c>CollapseAuditLogToCanonical</c> (C5, Task 2.5) migration rebuilt
|
||||
/// the table and added <c>IX_AuditLog_Execution</c>,
|
||||
/// <c>IX_AuditLog_ParentExecution</c>, <c>IX_AuditLog_Node_Occurred</c>,
|
||||
/// and <c>UX_AuditLog_EventId</c> — nine in total.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_CreatesFiveNamedIndexes()
|
||||
public async Task AppliesMigration_CreatesNineNamedIndexes()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// All nine named non-clustered indexes present on dbo.AuditLog after
|
||||
// the full migration history is applied (AddAuditLogTable through
|
||||
// CollapseAuditLogToCanonical).
|
||||
var expected = new[]
|
||||
{
|
||||
// Original five (AddAuditLogTable / AddAuditLogSourceNode):
|
||||
"IX_AuditLog_OccurredAtUtc",
|
||||
"IX_AuditLog_Site_Occurred",
|
||||
"IX_AuditLog_CorrelationId",
|
||||
"IX_AuditLog_Channel_Status_Occurred",
|
||||
"IX_AuditLog_Target_Occurred",
|
||||
// Added by CollapseAuditLogToCanonical (C5, Task 2.5):
|
||||
"IX_AuditLog_Execution",
|
||||
"IX_AuditLog_ParentExecution",
|
||||
"IX_AuditLog_Node_Occurred",
|
||||
"UX_AuditLog_EventId",
|
||||
};
|
||||
|
||||
foreach (var indexName in expected)
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// C7 (Task 2.5) data-projection tests for the <c>CollapseAuditLogToCanonical</c>
|
||||
/// migration. Verifies that the canonical column layout and six persisted computed
|
||||
/// columns are correct after the migration has been applied:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Action</c> = "{Channel}.{Kind}" per <see cref="AuditFieldBuilders.BuildAction"/>.</item>
|
||||
/// <item><c>Category</c> = Channel name per <see cref="AuditFieldBuilders.BuildCategory"/>.</item>
|
||||
/// <item><c>Outcome</c> derived via <see cref="AuditOutcomeProjector.Project"/>:
|
||||
/// <c>InboundAuthFailure</c> → <c>Denied</c>;
|
||||
/// Status ∈ {<c>Failed</c>, <c>Parked</c>, <c>Discarded</c>} → <c>Failure</c>;
|
||||
/// else → <c>Success</c>.</item>
|
||||
/// <item>Empty <c>Actor</c> maps to NULL.</item>
|
||||
/// <item><c>DetailsJson</c> produced by <see cref="AuditDetailsCodec.Serialize"/> round-trips
|
||||
/// correctly and the six persisted computed columns (<c>Kind</c>, <c>Status</c>,
|
||||
/// <c>SourceSiteId</c>, <c>ExecutionId</c>, <c>ParentExecutionId</c>) resolve to
|
||||
/// the expected values via <c>JSON_VALUE</c>.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The fixture applies the FULL migration history (via
|
||||
/// <see cref="MsSqlMigrationFixture"/>), so this test exercises the post-migration
|
||||
/// canonical table shape. Rather than using a two-phase fixture (apply up to C4,
|
||||
/// seed, apply C5), the tests insert rows directly into the canonical table using
|
||||
/// <see cref="AuditDetailsCodec"/> — the same codec the migration's
|
||||
/// <c>FOR JSON PATH, WITHOUT_ARRAY_WRAPPER</c> projection is designed to match —
|
||||
/// and verify that all six computed columns resolve correctly from <c>DetailsJson</c>.
|
||||
/// This validates the computed-column SQL expressions that are the source of truth
|
||||
/// for both the live table and <c>SwitchOutPartitionAsync</c>'s staging table.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Tests use <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot(...)</c> so the
|
||||
/// runner reports them as Skipped (not Passed) when MSSQL is unreachable.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class CollapseAuditLogToCanonicalMigrationTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public CollapseAuditLogToCanonicalMigrationTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A representative ApiOutbound.ApiCall row with populated domain fields.
|
||||
/// Verifies: Action, Category, Outcome projection; DetailsJson round-trip;
|
||||
/// Kind/Status/SourceSiteId/ExecutionId/ParentExecutionId computed columns.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CanonicalRow_ApiOutbound_ComputedColumns_ResolveCorrectly()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var eventId = Guid.NewGuid();
|
||||
var executionId = Guid.NewGuid();
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
var occurredAt = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var details = new AuditDetails
|
||||
{
|
||||
Channel = AuditChannel.ApiOutbound.ToString(),
|
||||
Kind = AuditKind.ApiCall.ToString(),
|
||||
Status = AuditStatus.Delivered.ToString(),
|
||||
ExecutionId = executionId,
|
||||
ParentExecutionId = parentExecutionId,
|
||||
SourceSiteId = "site-alpha",
|
||||
HttpStatus = 200,
|
||||
DurationMs = 42,
|
||||
RequestSummary = "{\"body\":\"hello\"}",
|
||||
PayloadTruncated = false,
|
||||
};
|
||||
var detailsJson = AuditDetailsCodec.Serialize(details);
|
||||
|
||||
var action = AuditFieldBuilders.BuildAction(AuditChannel.ApiOutbound, AuditKind.ApiCall);
|
||||
var category = AuditFieldBuilders.BuildCategory(AuditChannel.ApiOutbound);
|
||||
// AuditStatus.Delivered → Outcome "Success" (not InboundAuthFailure, not Failed/Parked/Discarded)
|
||||
var outcome = AuditOutcomeProjector.Project(AuditStatus.Delivered, AuditKind.ApiCall);
|
||||
var outcomeStr = outcome.ToString(); // "Success"
|
||||
|
||||
await InsertCanonicalRowAsync(
|
||||
eventId, occurredAt,
|
||||
actor: "svc-account",
|
||||
action: action,
|
||||
outcome: outcomeStr,
|
||||
category: category,
|
||||
target: "ext-api.endpoint",
|
||||
sourceNode: "node-a",
|
||||
correlationId: null,
|
||||
detailsJson: detailsJson);
|
||||
|
||||
// ── Verify canonical top-level columns ─────────────────────────────
|
||||
var row = await ReadCanonicalRowAsync(eventId, occurredAt);
|
||||
Assert.NotNull(row);
|
||||
|
||||
Assert.Equal(action, row.Action); // "ApiOutbound.ApiCall"
|
||||
Assert.Equal(category, row.Category); // "ApiOutbound"
|
||||
Assert.Equal("Success", row.Outcome); // Delivered → Success
|
||||
Assert.Equal("svc-account", row.Actor);
|
||||
|
||||
// ── Verify persisted computed columns (JSON_VALUE expressions) ──────
|
||||
Assert.Equal(AuditKind.ApiCall.ToString(), row.Kind);
|
||||
Assert.Equal(AuditStatus.Delivered.ToString(), row.Status);
|
||||
Assert.Equal("site-alpha", row.SourceSiteId);
|
||||
Assert.Equal(executionId, row.ExecutionId);
|
||||
Assert.Equal(parentExecutionId, row.ParentExecutionId);
|
||||
|
||||
// ── Verify DetailsJson round-trips through codec ────────────────────
|
||||
var roundTripped = AuditDetailsCodec.Deserialize(row.DetailsJson);
|
||||
Assert.Equal(AuditChannel.ApiOutbound.ToString(), roundTripped.Channel);
|
||||
Assert.Equal(AuditKind.ApiCall.ToString(), roundTripped.Kind);
|
||||
Assert.Equal(AuditStatus.Delivered.ToString(), roundTripped.Status);
|
||||
Assert.Equal("site-alpha", roundTripped.SourceSiteId);
|
||||
Assert.Equal(executionId, roundTripped.ExecutionId);
|
||||
Assert.Equal(parentExecutionId, roundTripped.ParentExecutionId);
|
||||
Assert.Equal(200, roundTripped.HttpStatus);
|
||||
Assert.Equal(42, roundTripped.DurationMs);
|
||||
Assert.False(roundTripped.PayloadTruncated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <c>InboundAuthFailure</c> row (channel = <c>ApiInbound</c>).
|
||||
/// The C5 migration projection rule: Kind=<c>InboundAuthFailure</c> → Outcome=<c>Denied</c>
|
||||
/// regardless of Status. Verifies this special-case projection is represented
|
||||
/// correctly in the canonical Outcome column.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CanonicalRow_InboundAuthFailure_Outcome_IsDenied()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var eventId = Guid.NewGuid();
|
||||
var occurredAt = new DateTime(2026, 6, 1, 13, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var details = new AuditDetails
|
||||
{
|
||||
Channel = AuditChannel.ApiInbound.ToString(),
|
||||
Kind = AuditKind.InboundAuthFailure.ToString(),
|
||||
Status = AuditStatus.Failed.ToString(),
|
||||
PayloadTruncated = false,
|
||||
};
|
||||
var detailsJson = AuditDetailsCodec.Serialize(details);
|
||||
|
||||
var action = AuditFieldBuilders.BuildAction(AuditChannel.ApiInbound, AuditKind.InboundAuthFailure);
|
||||
var category = AuditFieldBuilders.BuildCategory(AuditChannel.ApiInbound);
|
||||
// InboundAuthFailure → Denied regardless of Status=Failed.
|
||||
var outcome = AuditOutcomeProjector.Project(AuditStatus.Failed, AuditKind.InboundAuthFailure);
|
||||
Assert.Equal("Denied", outcome.ToString()); // pre-condition: factory applies same rule
|
||||
|
||||
await InsertCanonicalRowAsync(
|
||||
eventId, occurredAt,
|
||||
actor: null, // unauthenticated — Actor NULL
|
||||
action: action,
|
||||
outcome: outcome.ToString(), // "Denied"
|
||||
category: category,
|
||||
target: "/api/route",
|
||||
sourceNode: "central-a",
|
||||
correlationId: null,
|
||||
detailsJson: detailsJson);
|
||||
|
||||
var row = await ReadCanonicalRowAsync(eventId, occurredAt);
|
||||
Assert.NotNull(row);
|
||||
|
||||
Assert.Equal("Denied", row.Outcome);
|
||||
Assert.Null(row.Actor); // NULL Actor preserved
|
||||
|
||||
// Computed columns
|
||||
Assert.Equal(AuditKind.InboundAuthFailure.ToString(), row.Kind);
|
||||
Assert.Equal(AuditStatus.Failed.ToString(), row.Status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <c>ApiOutbound.ApiCall</c> row with <c>Status=Failed</c>.
|
||||
/// Projection rule: Status ∈ {Failed, Parked, Discarded} → Outcome=<c>Failure</c>.
|
||||
/// Verifies the failure-branch projection that applies to non-auth failures.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CanonicalRow_FailedStatus_Outcome_IsFailure()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var eventId = Guid.NewGuid();
|
||||
var occurredAt = new DateTime(2026, 6, 1, 14, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var details = new AuditDetails
|
||||
{
|
||||
Channel = AuditChannel.ApiOutbound.ToString(),
|
||||
Kind = AuditKind.ApiCall.ToString(),
|
||||
Status = AuditStatus.Failed.ToString(),
|
||||
ErrorMessage = "connection refused",
|
||||
PayloadTruncated = false,
|
||||
};
|
||||
var detailsJson = AuditDetailsCodec.Serialize(details);
|
||||
|
||||
var outcome = AuditOutcomeProjector.Project(AuditStatus.Failed, AuditKind.ApiCall);
|
||||
Assert.Equal("Failure", outcome.ToString()); // pre-condition
|
||||
|
||||
await InsertCanonicalRowAsync(
|
||||
eventId, occurredAt,
|
||||
actor: "svc-account",
|
||||
action: AuditFieldBuilders.BuildAction(AuditChannel.ApiOutbound, AuditKind.ApiCall),
|
||||
outcome: outcome.ToString(),
|
||||
category: AuditFieldBuilders.BuildCategory(AuditChannel.ApiOutbound),
|
||||
target: "ext-api.endpoint",
|
||||
sourceNode: "node-b",
|
||||
correlationId: null,
|
||||
detailsJson: detailsJson);
|
||||
|
||||
var row = await ReadCanonicalRowAsync(eventId, occurredAt);
|
||||
Assert.NotNull(row);
|
||||
|
||||
Assert.Equal("Failure", row.Outcome);
|
||||
Assert.Equal(AuditStatus.Failed.ToString(), row.Status);
|
||||
|
||||
// DetailsJson round-trip preserves ErrorMessage
|
||||
var roundTripped = AuditDetailsCodec.Deserialize(row.DetailsJson);
|
||||
Assert.Equal("connection refused", roundTripped.ErrorMessage);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task InsertCanonicalRowAsync(
|
||||
Guid eventId,
|
||||
DateTime occurredAt,
|
||||
string? actor,
|
||||
string action,
|
||||
string outcome,
|
||||
string category,
|
||||
string? target,
|
||||
string? sourceNode,
|
||||
Guid? correlationId,
|
||||
string detailsJson)
|
||||
{
|
||||
await using var conn = _fixture.OpenConnection();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
INSERT INTO dbo.AuditLog
|
||||
(EventId, OccurredAtUtc, Actor, Action, Outcome, Category,
|
||||
Target, SourceNode, CorrelationId, DetailsJson)
|
||||
VALUES
|
||||
(@EventId, @OccurredAtUtc, @Actor, @Action, @Outcome, @Category,
|
||||
@Target, @SourceNode, @CorrelationId, @DetailsJson);";
|
||||
|
||||
cmd.Parameters.AddWithValue("@EventId", eventId);
|
||||
cmd.Parameters.AddWithValue("@OccurredAtUtc", occurredAt);
|
||||
cmd.Parameters.AddWithValue("@Actor", (object?)actor ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@Action", action);
|
||||
cmd.Parameters.AddWithValue("@Outcome", outcome);
|
||||
cmd.Parameters.AddWithValue("@Category", category);
|
||||
cmd.Parameters.AddWithValue("@Target", (object?)target ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@SourceNode", (object?)sourceNode ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@CorrelationId", (object?)correlationId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@DetailsJson", detailsJson);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private async Task<CanonicalRow?> ReadCanonicalRowAsync(Guid eventId, DateTime occurredAt)
|
||||
{
|
||||
await using var conn = _fixture.OpenConnection();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
// Read all canonical and computed columns for the inserted row.
|
||||
cmd.CommandText = @"
|
||||
SELECT
|
||||
Action, Category, Outcome, Actor, DetailsJson,
|
||||
Kind, Status, SourceSiteId, ExecutionId, ParentExecutionId
|
||||
FROM dbo.AuditLog
|
||||
WHERE EventId = @EventId AND OccurredAtUtc = @OccurredAtUtc;";
|
||||
cmd.Parameters.AddWithValue("@EventId", eventId);
|
||||
cmd.Parameters.AddWithValue("@OccurredAtUtc", occurredAt);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
if (!await reader.ReadAsync())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new CanonicalRow(
|
||||
Action: reader.GetString(0),
|
||||
Category: reader.GetString(1),
|
||||
Outcome: reader.GetString(2),
|
||||
Actor: reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
DetailsJson: reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
Kind: reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
Status: reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
SourceSiteId: reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
ExecutionId: reader.IsDBNull(8) ? null : reader.GetGuid(8),
|
||||
ParentExecutionId: reader.IsDBNull(9) ? null : reader.GetGuid(9));
|
||||
}
|
||||
|
||||
private sealed record CanonicalRow(
|
||||
string Action,
|
||||
string Category,
|
||||
string Outcome,
|
||||
string? Actor,
|
||||
string? DetailsJson,
|
||||
string? Kind,
|
||||
string? Status,
|
||||
string? SourceSiteId,
|
||||
Guid? ExecutionId,
|
||||
Guid? ParentExecutionId);
|
||||
}
|
||||
Reference in New Issue
Block a user