feat(audit): ScadaBridge C4 — site SQLite two-table (audit_event canonical + audit_forward_state sidecar), forwarding on sidecar, IsCachedKind drain split (Task 2.5)

This commit is contained in:
Joseph Doherty
2026-06-02 13:11:20 -04:00
parent c27b2c3d5f
commit 946d3e2aef
3 changed files with 689 additions and 704 deletions
@@ -10,9 +10,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Bundle B (M2-T1) schema-bootstrap tests for <see cref="SqliteAuditWriter"/>.
/// Uses an in-memory shared-cache SQLite database so the same connection name
/// reaches the same file-less db across both the writer and the verifier.
/// C4 (Task 2.5) schema-bootstrap tests for <see cref="SqliteAuditWriter"/>'s
/// two-table site schema — the append-only canonical <c>audit_event</c> table +
/// the mutable operational <c>audit_forward_state</c> sidecar + the <c>IX_fwd</c>
/// drain index. Uses an in-memory shared-cache SQLite database so the same
/// connection name reaches the same file-less db across both the writer and the
/// verifier.
/// </summary>
public class SqliteAuditWriterSchemaTests
{
@@ -38,6 +41,16 @@ public class SqliteAuditWriterSchemaTests
return (writer, dataSource);
}
private static SqliteAuditWriter CreateWriterOver(string dataSource)
{
var options = new SqliteAuditWriterOptions { DatabasePath = dataSource };
return new SqliteAuditWriter(
Options.Create(options),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
}
private static SqliteConnection OpenVerifierConnection(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
@@ -45,15 +58,37 @@ public class SqliteAuditWriterSchemaTests
return connection;
}
[Fact]
public void Opens_Creates_AuditLog_Table_With_23Columns_And_PK_On_EventId()
private static List<string> ColumnNames(SqliteConnection connection, string table)
{
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_23Columns_And_PK_On_EventId));
using var cmd = connection.CreateCommand();
cmd.CommandText = $"PRAGMA table_info({table});";
using var reader = cmd.ExecuteReader();
var names = new List<string>();
while (reader.Read())
{
names.Add(reader.GetString(1));
}
return names;
}
private static bool TableExists(SqliteConnection connection, string table)
{
using var cmd = connection.CreateCommand();
cmd.CommandText =
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $name;";
cmd.Parameters.AddWithValue("$name", table);
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
}
[Fact]
public void Opens_Creates_audit_event_Canonical_Table_With_10Columns_And_PK_On_EventId()
{
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_audit_event_Canonical_Table_With_10Columns_And_PK_On_EventId));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA table_info(AuditLog);";
cmd.CommandText = "PRAGMA table_info(audit_event);";
using var reader = cmd.ExecuteReader();
var columns = new List<(string Name, int Pk)>();
@@ -62,16 +97,13 @@ public class SqliteAuditWriterSchemaTests
columns.Add((reader.GetString(1), reader.GetInt32(5)));
}
Assert.Equal(23, columns.Count);
// The 10 canonical ZB.MOM.WW.Audit.AuditEvent fields, stored directly.
var expected = new[]
{
"EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId",
"SourceSiteId", "SourceNode", "SourceInstanceId", "SourceScript", "Actor", "Target",
"Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail",
"RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra",
"ForwardState", "ExecutionId", "ParentExecutionId",
"EventId", "OccurredAtUtc", "Actor", "Action", "Outcome",
"Category", "Target", "SourceNode", "CorrelationId", "DetailsJson",
};
Assert.Equal(10, columns.Count);
Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n));
// PK is EventId only.
@@ -82,27 +114,46 @@ public class SqliteAuditWriterSchemaTests
}
[Fact]
public void Initialize_creates_AuditLog_with_SourceNode_column()
public void Opens_Creates_audit_forward_state_Sidecar_Table_With_Expected_Columns()
{
var (writer, dataSource) = CreateWriter(nameof(Initialize_creates_AuditLog_with_SourceNode_column));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
Assert.True(
ColumnExists(connection, "SourceNode"),
"Fresh AuditLog schema must include the SourceNode column.");
}
}
[Fact]
public void Opens_Creates_IX_ForwardState_Occurred_Index()
{
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_IX_ForwardState_Occurred_Index));
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_audit_forward_state_Sidecar_Table_With_Expected_Columns));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA index_list(AuditLog);";
cmd.CommandText = "PRAGMA table_info(audit_forward_state);";
using var reader = cmd.ExecuteReader();
var columns = new List<(string Name, int Pk)>();
while (reader.Read())
{
columns.Add((reader.GetString(1), reader.GetInt32(5)));
}
var expected = new[]
{
"EventId", "ForwardState", "OccurredAtUtc",
"IsCachedKind", "AttemptCount", "LastAttemptUtc",
};
Assert.Equal(6, columns.Count);
Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n));
// PK is EventId only.
var pkColumns = columns.Where(c => c.Pk > 0).Select(c => c.Name).ToList();
Assert.Single(pkColumns);
Assert.Equal("EventId", pkColumns[0]);
}
}
[Fact]
public void Opens_Creates_IX_fwd_Index_On_ForwardState_IsCachedKind_Occurred()
{
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_IX_fwd_Index_On_ForwardState_IsCachedKind_Occurred));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA index_list(audit_forward_state);";
using var reader = cmd.ExecuteReader();
var indexNames = new List<string>();
@@ -111,11 +162,12 @@ public class SqliteAuditWriterSchemaTests
indexNames.Add(reader.GetString(1));
}
Assert.Contains("IX_SiteAuditLog_ForwardState_Occurred", indexNames);
Assert.Contains("IX_fwd", indexNames);
// Verify the index columns are ForwardState, OccurredAtUtc in that order.
// Verify the index columns are ForwardState, IsCachedKind, OccurredAtUtc
// in that order.
using var infoCmd = connection.CreateCommand();
infoCmd.CommandText = "PRAGMA index_info(IX_SiteAuditLog_ForwardState_Occurred);";
infoCmd.CommandText = "PRAGMA index_info(IX_fwd);";
using var infoReader = infoCmd.ExecuteReader();
var indexColumns = new List<string>();
@@ -124,7 +176,7 @@ public class SqliteAuditWriterSchemaTests
indexColumns.Add(infoReader.GetString(2));
}
Assert.Equal(new[] { "ForwardState", "OccurredAtUtc" }, indexColumns);
Assert.Equal(new[] { "ForwardState", "IsCachedKind", "OccurredAtUtc" }, indexColumns);
}
}
@@ -144,258 +196,17 @@ public class SqliteAuditWriterSchemaTests
}
}
// ----- ExecutionId schema-upgrade regression (persistent auditlog.db) ----- //
// ----- C4 ephemeral in-place reset: old single-table schema is dropped ----- //
/// <summary>
/// The OLD pre-ExecutionId-branch <c>AuditLog</c> schema — the 20-column
/// CREATE TABLE WITHOUT the <c>ExecutionId</c> column. A real deployment's
/// on-disk <c>auditlog.db</c> already contains exactly this shape, and
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// The OLD pre-C4 single 24-column <c>AuditLog</c> table — exactly the shape a
/// pre-C4 deployment's on-disk <c>auditlog.db</c> contains. The site store is
/// ephemeral (≈7-day retention, recreated per deployment), so C4 RESETS in
/// place: the new two-table schema is created and this old table is DROP-ped.
/// No SQLite data migration is performed (or needed) — any rows it holds are
/// within the retention window and discarded.
/// </summary>
private const string OldPreExecutionIdSchema = """
CREATE TABLE IF NOT EXISTS AuditLog (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Channel TEXT NOT NULL,
Kind TEXT NOT NULL,
CorrelationId TEXT NULL,
SourceSiteId TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
Target TEXT NULL,
Status TEXT NOT NULL,
HttpStatus INTEGER NULL,
DurationMs INTEGER NULL,
ErrorMessage TEXT NULL,
ErrorDetail TEXT NULL,
RequestSummary TEXT NULL,
ResponseSummary TEXT NULL,
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
""";
/// <summary>
/// Seeds a shared-cache in-memory database with the OLD 20-column schema and
/// returns the open connection. The connection MUST stay open for the
/// lifetime of the test: a shared-cache in-memory database is dropped once
/// its last connection closes, so closing this would discard the seeded
/// schema before the writer opens its own connection.
/// </summary>
private static SqliteConnection SeedOldSchemaDatabase(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreExecutionIdSchema;
cmd.ExecuteNonQuery();
return connection;
}
private static SqliteAuditWriter CreateWriterOver(string dataSource)
{
var options = new SqliteAuditWriterOptions { DatabasePath = dataSource };
return new SqliteAuditWriter(
Options.Create(options),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
}
private static bool ColumnExists(SqliteConnection connection, string columnName)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name";
cmd.Parameters.AddWithValue("$name", columnName);
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
}
[Fact]
public async Task Opening_Over_PreExisting_OldSchema_Db_Adds_ExecutionId_Column_And_WriteAsync_RoundTrips()
{
var dataSource = $"file:{nameof(Opening_Over_PreExisting_OldSchema_Db_Adds_ExecutionId_Column_And_WriteAsync_RoundTrips)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// A pre-branch deployment: auditlog.db already exists with the 20-column
// schema and NO ExecutionId column.
using var seedConnection = SeedOldSchemaDatabase(dataSource);
Assert.False(ColumnExists(seedConnection, "ExecutionId"));
// Upgrade: a post-branch SqliteAuditWriter opens the same database. Its
// InitializeSchema must ALTER the missing ExecutionId column in — the
// CREATE TABLE IF NOT EXISTS alone is a no-op against the existing table.
var executionId = Guid.NewGuid();
await using (var writer = CreateWriterOver(dataSource))
{
Assert.True(
ColumnExists(seedConnection, "ExecutionId"),
"SqliteAuditWriter must ALTER the ExecutionId column into a pre-existing AuditLog table.");
// A WriteAsync binding $ExecutionId must now succeed and round-trip;
// without the ALTER it would fail with "no such column: ExecutionId"
// and — because audit writes are best-effort — silently drop the row.
var evt = ScadaBridgeAuditEventFactory.Create(
eventId: Guid.NewGuid(),
occurredAtUtc: DateTime.UtcNow,
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
executionId: executionId);
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(executionId, row.AsRow().ExecutionId);
}
// Idempotency: a second writer over the now-upgraded DB must not error
// (the probe sees ExecutionId already present and skips the ALTER).
await using (var writerAgain = CreateWriterOver(dataSource))
{
Assert.True(ColumnExists(seedConnection, "ExecutionId"));
}
}
// ----- ParentExecutionId schema-upgrade regression (persistent auditlog.db) ----- //
/// <summary>
/// The pre-ParentExecutionId-branch <c>AuditLog</c> schema — the 21-column
/// CREATE TABLE that HAS <c>ExecutionId</c> but is WITHOUT
/// <c>ParentExecutionId</c>. A deployment that ran the ExecutionId branch
/// already has an on-disk <c>auditlog.db</c> in exactly this shape, and
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// </summary>
private const string OldPreParentExecutionIdSchema = """
CREATE TABLE IF NOT EXISTS AuditLog (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Channel TEXT NOT NULL,
Kind TEXT NOT NULL,
CorrelationId TEXT NULL,
SourceSiteId TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
Target TEXT NULL,
Status TEXT NOT NULL,
HttpStatus INTEGER NULL,
DurationMs INTEGER NULL,
ErrorMessage TEXT NULL,
ErrorDetail TEXT NULL,
RequestSummary TEXT NULL,
ResponseSummary TEXT NULL,
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
ExecutionId TEXT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
""";
/// <summary>
/// Seeds a shared-cache in-memory database with the pre-ParentExecutionId
/// 21-column schema and returns the open connection. The connection MUST
/// stay open for the lifetime of the test — a shared-cache in-memory
/// database is dropped once its last connection closes.
/// </summary>
private static SqliteConnection SeedPreParentExecutionIdSchemaDatabase(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreParentExecutionIdSchema;
cmd.ExecuteNonQuery();
return connection;
}
[Fact]
public async Task Opening_Over_PreExisting_PreParentExecutionId_Db_Adds_ParentExecutionId_Column_And_WriteAsync_RoundTrips()
{
var dataSource = $"file:{nameof(Opening_Over_PreExisting_PreParentExecutionId_Db_Adds_ParentExecutionId_Column_And_WriteAsync_RoundTrips)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// A deployment that ran the ExecutionId branch: auditlog.db already
// exists with the 21-column schema and NO ParentExecutionId column.
using var seedConnection = SeedPreParentExecutionIdSchemaDatabase(dataSource);
Assert.True(ColumnExists(seedConnection, "ExecutionId"));
Assert.False(ColumnExists(seedConnection, "ParentExecutionId"));
// Upgrade: a post-branch SqliteAuditWriter opens the same database. Its
// InitializeSchema must ALTER the missing ParentExecutionId column in —
// the CREATE TABLE IF NOT EXISTS alone is a no-op against the existing
// table.
var executionId = Guid.NewGuid();
var parentExecutionId = Guid.NewGuid();
await using (var writer = CreateWriterOver(dataSource))
{
Assert.True(
ColumnExists(seedConnection, "ParentExecutionId"),
"SqliteAuditWriter must ALTER the ParentExecutionId column into a pre-existing AuditLog table.");
// A WriteAsync binding $ParentExecutionId must now succeed and
// round-trip; without the ALTER it would fail with "no such column:
// ParentExecutionId" and — because audit writes are best-effort —
// silently drop the row.
var evt = ScadaBridgeAuditEventFactory.Create(
eventId: Guid.NewGuid(),
occurredAtUtc: DateTime.UtcNow,
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
executionId: executionId,
parentExecutionId: parentExecutionId);
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(executionId, row.AsRow().ExecutionId);
Assert.Equal(parentExecutionId, row.AsRow().ParentExecutionId);
}
// Idempotency: a second writer over the now-upgraded DB must not error
// (the probe sees ParentExecutionId already present and skips the ALTER).
await using (var writerAgain = CreateWriterOver(dataSource))
{
Assert.True(ColumnExists(seedConnection, "ParentExecutionId"));
}
}
[Fact]
public async Task WriteAsync_NullParentExecutionId_RoundTripsAsNull()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_NullParentExecutionId_RoundTripsAsNull));
await using (writer)
{
var evt = ScadaBridgeAuditEventFactory.Create(
eventId: Guid.NewGuid(),
occurredAtUtc: DateTime.UtcNow,
channel: AuditChannel.Notification,
kind: AuditKind.NotifySend,
status: AuditStatus.Submitted);
// ParentExecutionId left null (not a factory arg → defaults null)
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Null(row.AsRow().ParentExecutionId);
}
}
// ----- SourceNode schema-upgrade regression (persistent auditlog.db) ----- //
/// <summary>
/// The pre-SourceNode <c>AuditLog</c> schema — the 22-column CREATE TABLE
/// that HAS <c>ExecutionId</c> + <c>ParentExecutionId</c> but is WITHOUT
/// <c>SourceNode</c>. A deployment that ran the ParentExecutionId branch
/// already has an on-disk <c>auditlog.db</c> in exactly this shape, and
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// </summary>
private const string OldPreSourceNodeSchema = """
private const string OldSingleTableSchema = """
CREATE TABLE IF NOT EXISTS AuditLog (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
@@ -403,6 +214,7 @@ public class SqliteAuditWriterSchemaTests
Kind TEXT NOT NULL,
CorrelationId TEXT NULL,
SourceSiteId TEXT NULL,
SourceNode TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
@@ -426,67 +238,84 @@ public class SqliteAuditWriterSchemaTests
""";
/// <summary>
/// Seeds a shared-cache in-memory database with the pre-SourceNode 22-column
/// schema and returns the open connection. The connection MUST stay open for
/// the lifetime of the test a shared-cache in-memory database is dropped
/// once its last connection closes.
/// Seeds a shared-cache in-memory database with the OLD single-table schema
/// and returns the open connection. The connection MUST stay open for the
/// lifetime of the test: a shared-cache in-memory database is dropped once its
/// last connection closes, so closing this would discard the seeded schema
/// before the writer opens its own connection.
/// </summary>
private static SqliteConnection SeedPreSourceNodeSchemaDatabase(string dataSource)
private static SqliteConnection SeedOldSingleTableDatabase(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreSourceNodeSchema;
cmd.CommandText = OldSingleTableSchema;
cmd.ExecuteNonQuery();
// Seed one row so we can prove the reset discards it (ephemeral store).
using var insert = connection.CreateCommand();
insert.CommandText = """
INSERT INTO AuditLog (
EventId, OccurredAtUtc, Channel, Kind, Status, PayloadTruncated, ForwardState
) VALUES (
$id, '2026-05-20T12:00:00.0000000Z', 'ApiOutbound', 'ApiCall', 'Delivered', 0, 'Pending'
);
""";
insert.Parameters.AddWithValue("$id", Guid.NewGuid().ToString());
insert.ExecuteNonQuery();
return connection;
}
[Fact]
public async Task Initialize_adds_SourceNode_to_pre_existing_schema()
public async Task Opening_Over_PreExisting_OldSingleTable_Db_Drops_It_And_Creates_Two_Table_Schema()
{
var dataSource = $"file:{nameof(Initialize_adds_SourceNode_to_pre_existing_schema)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
var dataSource = $"file:{nameof(Opening_Over_PreExisting_OldSingleTable_Db_Drops_It_And_Creates_Two_Table_Schema)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// A deployment that ran the ParentExecutionId branch: auditlog.db
// already exists with the 22-column schema and NO SourceNode column.
using var seedConnection = SeedPreSourceNodeSchemaDatabase(dataSource);
Assert.True(ColumnExists(seedConnection, "ExecutionId"));
Assert.True(ColumnExists(seedConnection, "ParentExecutionId"));
Assert.False(ColumnExists(seedConnection, "SourceNode"));
// A pre-C4 deployment: auditlog.db already exists with the old single
// 24-column AuditLog table (and a seeded row inside it).
using var seedConnection = SeedOldSingleTableDatabase(dataSource);
Assert.True(TableExists(seedConnection, "AuditLog"));
// Upgrade: a post-branch SqliteAuditWriter opens the same database. Its
// InitializeSchema must ALTER the missing SourceNode column in — the
// CREATE TABLE IF NOT EXISTS alone is a no-op against the existing table.
// Upgrade: a C4 SqliteAuditWriter opens the same database. Its
// InitializeSchema RESETS in place — the old AuditLog table is dropped and
// the two new tables (+ IX_fwd) are created. No data is migrated.
await using (var writer = CreateWriterOver(dataSource))
{
Assert.True(
ColumnExists(seedConnection, "SourceNode"),
"SqliteAuditWriter must ALTER the SourceNode column into a pre-existing AuditLog table.");
Assert.False(
TableExists(seedConnection, "AuditLog"),
"C4 must DROP the old single-table AuditLog on init (ephemeral in-place reset).");
Assert.True(TableExists(seedConnection, "audit_event"));
Assert.True(TableExists(seedConnection, "audit_forward_state"));
// A WriteAsync binding $SourceNode must now succeed and round-trip;
// without the ALTER it would fail with "no such column: SourceNode"
// and — because audit writes are best-effort — silently drop the row.
// The two new tables start EMPTY — the old row was discarded, not
// migrated (the site store is ephemeral).
Assert.Empty(await writer.ReadPendingAsync(limit: 100));
// And a fresh WriteAsync round-trips through the new schema.
var evt = ScadaBridgeAuditEventFactory.Create(
eventId: Guid.NewGuid(),
occurredAtUtc: DateTime.UtcNow,
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
sourceNode: "node-a");
status: AuditStatus.Delivered);
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal("node-a", row.SourceNode);
Assert.Equal(evt.EventId, row.EventId);
}
// Idempotency: a second writer over the now-upgraded DB must not error
// (the probe sees SourceNode already present and skips the ALTER).
// Idempotency: a second writer over the now-two-table DB must not error
// (DROP TABLE IF EXISTS is a no-op when AuditLog is already gone, and the
// CREATE TABLE IF NOT EXISTS statements are no-ops too).
await using (var writerAgain = CreateWriterOver(dataSource))
{
Assert.True(ColumnExists(seedConnection, "SourceNode"));
Assert.True(TableExists(seedConnection, "audit_event"));
Assert.True(TableExists(seedConnection, "audit_forward_state"));
}
}
// ----- Canonical / sidecar field persistence ----- //
[Fact]
public async Task WriteAsync_persists_SourceNode_field()
{
@@ -528,4 +357,31 @@ public class SqliteAuditWriterSchemaTests
Assert.Null(row.SourceNode);
}
}
[Fact]
public async Task WriteAsync_ExecutionId_RoundTrips_Through_DetailsJson()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_ExecutionId_RoundTrips_Through_DetailsJson));
await using (writer)
{
var executionId = Guid.NewGuid();
var parentExecutionId = Guid.NewGuid();
var evt = ScadaBridgeAuditEventFactory.Create(
eventId: Guid.NewGuid(),
occurredAtUtc: DateTime.UtcNow,
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
executionId: executionId,
parentExecutionId: parentExecutionId);
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
// ExecutionId / ParentExecutionId ride inside DetailsJson; AsRow()
// decomposes them back out.
Assert.Equal(executionId, row.AsRow().ExecutionId);
Assert.Equal(parentExecutionId, row.AsRow().ParentExecutionId);
}
}
}
@@ -11,12 +11,13 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Bundle B (M2-T2) hot-path tests for <see cref="SqliteAuditWriter"/>. Exercise
/// the Channel-based enqueue, the background writer's batch INSERTs, duplicate-
/// EventId swallowing, ForwardState defaulting, and the
/// <see cref="SqliteAuditWriter.ReadPendingAsync"/> /
/// <see cref="SqliteAuditWriter.MarkForwardedAsync"/> support surface that
/// Bundle D's telemetry actor will call.
/// C4 (Task 2.5) hot-path + drain tests for <see cref="SqliteAuditWriter"/>'s
/// two-table site schema. Exercise the Channel-based enqueue, the background
/// writer's per-event canonical(<c>audit_event</c>) + sidecar
/// (<c>audit_forward_state</c>) INSERTs, duplicate-EventId swallowing, the
/// <c>IsCachedKind</c> drain split, the four reads, and the
/// <see cref="SqliteAuditWriter.MarkForwardedAsync"/> /
/// <see cref="SqliteAuditWriter.MarkReconciledAsync"/> sidecar flips.
/// </summary>
public class SqliteAuditWriterWriteTests
{
@@ -52,10 +53,40 @@ public class SqliteAuditWriterWriteTests
return connection;
}
// C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
// factory. The SQLite writer's transitional shim decomposes it into the 24 columns
// (defaulting ForwardState=Pending) on INSERT and recomposes the canonical record
// on read. ExecutionId/SourceNode ride through DetailsJson / the top-level field.
/// <summary>
/// Reads the sidecar <c>ForwardState</c> for one EventId (the column moved off
/// the single legacy table onto <c>audit_forward_state</c> in C4).
/// </summary>
private static string? ReadForwardState(string dataSource, Guid eventId)
{
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState FROM audit_forward_state WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", eventId.ToString());
return cmd.ExecuteScalar() as string;
}
/// <summary>Sidecar ForwardState → row-count, grouped (replaces the legacy single-table GROUP BY).</summary>
private static Dictionary<string, long> ForwardStateCounts(string dataSource)
{
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText =
"SELECT ForwardState, COUNT(*) FROM audit_forward_state GROUP BY ForwardState;";
using var reader = cmd.ExecuteReader();
var byState = new Dictionary<string, long>();
while (reader.Read())
{
byState[reader.GetString(0)] = reader.GetInt64(1);
}
return byState;
}
// C4 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
// factory. The SQLite writer stores the 10 canonical fields directly in
// audit_event and writes a Pending sidecar row into audit_forward_state, with
// IsCachedKind precomputed from the event's Kind. Reads recompose the canonical
// record directly from audit_event's columns.
private static AuditEvent NewEvent(
Guid? id = null,
DateTime? occurredAtUtc = null,
@@ -70,22 +101,62 @@ public class SqliteAuditWriterWriteTests
executionId: executionId,
sourceNode: sourceNode);
/// <summary>A cached-lifecycle event (IsCachedKind=1) — drains via the cached read surface.</summary>
private static AuditEvent NewCachedEvent(
Guid? id = null,
DateTime? occurredAtUtc = null,
AuditKind kind = AuditKind.ApiCallCached)
// Status is independent of IsCachedKind (which is derived from Kind);
// Submitted is the natural first-row status for a cached lifecycle.
=> ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: kind,
status: AuditStatus.Submitted,
eventId: id ?? Guid.NewGuid(),
occurredAtUtc: occurredAtUtc ?? DateTime.UtcNow);
[Fact]
public async Task WriteAsync_FreshEvent_PersistsWithForwardStatePending()
public async Task WriteAsync_FreshEvent_PersistsCanonical_And_SidecarPending()
{
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_FreshEvent_PersistsWithForwardStatePending));
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_FreshEvent_PersistsCanonical_And_SidecarPending));
await using var _ = writer;
var evt = NewEvent();
await writer.WriteAsync(evt);
// Canonical row landed in audit_event.
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
var actual = cmd.ExecuteScalar() as string;
using var eventCmd = connection.CreateCommand();
eventCmd.CommandText = "SELECT Action FROM audit_event WHERE EventId = $id;";
eventCmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
Assert.Equal(evt.Action, eventCmd.ExecuteScalar() as string);
Assert.Equal(AuditForwardState.Pending.ToString(), actual);
// Sidecar row landed Pending.
Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, evt.EventId));
}
[Fact]
public async Task WriteAsync_Roundtrips_Canonical_Fields_Through_Read()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_Roundtrips_Canonical_Fields_Through_Read));
await using var _w = writer;
var evt = NewEvent() with { Target = "target-1", Actor = "user-1" };
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(evt.EventId, row.EventId);
Assert.Equal(evt.OccurredAtUtc, row.OccurredAtUtc);
Assert.Equal("user-1", row.Actor);
Assert.Equal(evt.Action, row.Action);
Assert.Equal(evt.Outcome, row.Outcome);
Assert.Equal(evt.Category, row.Category);
Assert.Equal("target-1", row.Target);
Assert.Equal(evt.CorrelationId, row.CorrelationId);
// DetailsJson is stored verbatim and round-trips byte-for-byte.
Assert.Equal(evt.DetailsJson, row.DetailsJson);
}
[Fact]
@@ -100,11 +171,14 @@ public class SqliteAuditWriterWriteTests
async (evt, ct) => await writer.WriteAsync(evt, ct));
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM AuditLog;";
var count = Convert.ToInt64(cmd.ExecuteScalar());
using var eventCmd = connection.CreateCommand();
eventCmd.CommandText = "SELECT COUNT(*) FROM audit_event;";
Assert.Equal(1000, Convert.ToInt64(eventCmd.ExecuteScalar()));
Assert.Equal(1000, count);
// Every canonical row has its matching sidecar row.
using var sidecarCmd = connection.CreateCommand();
sidecarCmd.CommandText = "SELECT COUNT(*) FROM audit_forward_state;";
Assert.Equal(1000, Convert.ToInt64(sidecarCmd.ExecuteScalar()));
}
[Fact]
@@ -122,36 +196,98 @@ public class SqliteAuditWriterWriteTests
using var connection = OpenVerifierConnection(dataSource);
using var countCmd = connection.CreateCommand();
countCmd.CommandText = "SELECT COUNT(*) FROM AuditLog WHERE EventId = $id;";
countCmd.CommandText = "SELECT COUNT(*) FROM audit_event WHERE EventId = $id;";
countCmd.Parameters.AddWithValue("$id", sharedId.ToString());
var count = Convert.ToInt64(countCmd.ExecuteScalar());
Assert.Equal(1, Convert.ToInt64(countCmd.ExecuteScalar()));
Assert.Equal(1, count);
// The sidecar likewise gained exactly one row (the canonical PK throws
// before the sidecar insert runs, so neither table double-inserts).
using var sidecarCmd = connection.CreateCommand();
sidecarCmd.CommandText = "SELECT COUNT(*) FROM audit_forward_state WHERE EventId = $id;";
sidecarCmd.Parameters.AddWithValue("$id", sharedId.ToString());
Assert.Equal(1, Convert.ToInt64(sidecarCmd.ExecuteScalar()));
using var targetCmd = connection.CreateCommand();
targetCmd.CommandText = "SELECT Target FROM AuditLog WHERE EventId = $id;";
targetCmd.CommandText = "SELECT Target FROM audit_event WHERE EventId = $id;";
targetCmd.Parameters.AddWithValue("$id", sharedId.ToString());
Assert.Equal("first", targetCmd.ExecuteScalar() as string);
}
[Fact]
public async Task WriteAsync_ForcesForwardStatePending_IfNull()
public async Task WriteAsync_ForcesSidecarForwardStatePending()
{
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesForwardStatePending_IfNull));
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_ForcesSidecarForwardStatePending));
await using var _ = writer;
// C3 (Task 2.5): ForwardState is no longer a field on the canonical record;
// a fresh canonical event carries none, and the SQLite shim defaults it to
// Pending on INSERT — exactly the behaviour this test pins.
// C4 (Task 2.5): ForwardState is not a field on the canonical record; a
// fresh event's sidecar row defaults to Pending on INSERT.
var evt = NewEvent();
await writer.WriteAsync(evt);
Assert.Equal(AuditForwardState.Pending.ToString(), ReadForwardState(dataSource, evt.EventId));
}
// ----- IsCachedKind drain split (precomputed at insert) ----- //
[Fact]
public async Task WriteAsync_CachedKind_SetsIsCachedKind_1_NonCached_0()
{
var (writer, dataSource) = CreateWriter(nameof(WriteAsync_CachedKind_SetsIsCachedKind_1_NonCached_0));
await using var _ = writer;
var cached = NewCachedEvent(); // ApiCallCached → cached
var nonCached = NewEvent(); // ApiCall → not cached
await writer.WriteAsync(cached);
await writer.WriteAsync(nonCached);
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
cmd.CommandText = "SELECT IsCachedKind FROM audit_forward_state WHERE EventId = $id;";
var p = cmd.Parameters.Add("$id", SqliteType.Text);
Assert.Equal(AuditForwardState.Pending.ToString(), cmd.ExecuteScalar() as string);
p.Value = cached.EventId.ToString();
Assert.Equal(1L, Convert.ToInt64(cmd.ExecuteScalar()));
p.Value = nonCached.EventId.ToString();
Assert.Equal(0L, Convert.ToInt64(cmd.ExecuteScalar()));
}
[Theory]
[InlineData(AuditKind.CachedSubmit)]
[InlineData(AuditKind.ApiCallCached)]
[InlineData(AuditKind.DbWriteCached)]
[InlineData(AuditKind.CachedResolve)]
public async Task CachedKinds_DrainVia_ReadPendingCachedTelemetry_Not_ReadPending(AuditKind kind)
{
var (writer, _) = CreateWriter($"{nameof(CachedKinds_DrainVia_ReadPendingCachedTelemetry_Not_ReadPending)}-{kind}");
await using var _w = writer;
var cached = NewCachedEvent(kind: kind);
await writer.WriteAsync(cached);
// The cached kind appears in the cached read surface...
var cachedRows = await writer.ReadPendingCachedTelemetryAsync(limit: 10);
Assert.Single(cachedRows, r => r.EventId == cached.EventId);
// ...and NOT in the audit-only read surface.
var pendingRows = await writer.ReadPendingAsync(limit: 10);
Assert.DoesNotContain(pendingRows, r => r.EventId == cached.EventId);
}
[Fact]
public async Task NonCachedKind_DrainsVia_ReadPending_Not_ReadPendingCachedTelemetry()
{
var (writer, _) = CreateWriter(nameof(NonCachedKind_DrainsVia_ReadPending_Not_ReadPendingCachedTelemetry));
await using var _w = writer;
var nonCached = NewEvent(); // ApiCall — not a cached kind
await writer.WriteAsync(nonCached);
var pendingRows = await writer.ReadPendingAsync(limit: 10);
Assert.Single(pendingRows, r => r.EventId == nonCached.EventId);
var cachedRows = await writer.ReadPendingCachedTelemetryAsync(limit: 10);
Assert.DoesNotContain(cachedRows, r => r.EventId == nonCached.EventId);
}
[Fact]
@@ -184,9 +320,9 @@ public class SqliteAuditWriterWriteTests
}
[Fact]
public async Task MarkForwardedAsync_FlipsRowsToForwarded()
public async Task MarkForwardedAsync_FlipsSidecarRowsToForwarded()
{
var (writer, dataSource) = CreateWriter(nameof(MarkForwardedAsync_FlipsRowsToForwarded));
var (writer, dataSource) = CreateWriter(nameof(MarkForwardedAsync_FlipsSidecarRowsToForwarded));
await using var _ = writer;
var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
@@ -197,20 +333,33 @@ public class SqliteAuditWriterWriteTests
await writer.MarkForwardedAsync(ids);
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState, COUNT(*) FROM AuditLog GROUP BY ForwardState;";
using var reader = cmd.ExecuteReader();
var byState = new Dictionary<string, long>();
while (reader.Read())
{
byState[reader.GetString(0)] = reader.GetInt64(1);
}
var byState = ForwardStateCounts(dataSource);
Assert.Equal(3, byState[AuditForwardState.Forwarded.ToString()]);
Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString()));
}
[Fact]
public async Task MarkForwardedAsync_BumpsAttemptCount_And_StampsLastAttemptUtc()
{
var (writer, dataSource) = CreateWriter(nameof(MarkForwardedAsync_BumpsAttemptCount_And_StampsLastAttemptUtc));
await using var _ = writer;
var evt = NewEvent();
await writer.WriteAsync(evt);
await writer.MarkForwardedAsync(new[] { evt.EventId });
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText =
"SELECT AttemptCount, LastAttemptUtc FROM audit_forward_state WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
using var reader = cmd.ExecuteReader();
Assert.True(reader.Read());
Assert.Equal(1, reader.GetInt32(0)); // AttemptCount bumped 0 → 1
Assert.False(reader.IsDBNull(1)); // LastAttemptUtc stamped
}
[Fact]
public async Task MarkForwardedAsync_NonExistentId_NoThrow()
{
@@ -223,6 +372,23 @@ public class SqliteAuditWriterWriteTests
// No assertion needed: the call must complete without throwing.
}
[Fact]
public async Task ReadForwardedAsync_Returns_Only_Forwarded_Rows()
{
var (writer, _) = CreateWriter(nameof(ReadForwardedAsync_Returns_Only_Forwarded_Rows));
await using var _w = writer;
var forwarded = NewEvent();
var pending = NewEvent();
await writer.WriteAsync(forwarded);
await writer.WriteAsync(pending);
await writer.MarkForwardedAsync(new[] { forwarded.EventId });
var rows = await writer.ReadForwardedAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(forwarded.EventId, row.EventId);
}
// ----- M6 reconciliation pull surface ----- //
[Fact]
@@ -327,16 +493,7 @@ public class SqliteAuditWriterWriteTests
await writer.MarkReconciledAsync(new[] { a.EventId, b.EventId, c.EventId });
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState, COUNT(*) FROM AuditLog GROUP BY ForwardState;";
using var reader = cmd.ExecuteReader();
var byState = new Dictionary<string, long>();
while (reader.Read())
{
byState[reader.GetString(0)] = reader.GetInt64(1);
}
var byState = ForwardStateCounts(dataSource);
Assert.Equal(3, byState[AuditForwardState.Reconciled.ToString()]);
Assert.False(byState.ContainsKey(AuditForwardState.Pending.ToString()));
Assert.False(byState.ContainsKey(AuditForwardState.Forwarded.ToString()));
@@ -354,12 +511,7 @@ public class SqliteAuditWriterWriteTests
// Re-call must not throw and must leave the single row Reconciled.
await writer.MarkReconciledAsync(new[] { a.EventId });
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT ForwardState FROM AuditLog WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", a.EventId.ToString());
Assert.Equal(AuditForwardState.Reconciled.ToString(), cmd.ExecuteScalar() as string);
Assert.Equal(AuditForwardState.Reconciled.ToString(), ReadForwardState(dataSource, a.EventId));
}
[Fact]
@@ -372,12 +524,12 @@ public class SqliteAuditWriterWriteTests
// Completes without throwing.
}
// ----- ExecutionId column (universal per-run correlation value) ----- //
// ----- ExecutionId (rides DetailsJson, recomposed via AsRow) ----- //
[Fact]
public async Task WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow()
public async Task WriteAsync_NonNullExecutionId_RoundTrips()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow));
var (writer, _) = CreateWriter(nameof(WriteAsync_NonNullExecutionId_RoundTrips));
await using var _w = writer;
var executionId = Guid.NewGuid();
@@ -458,46 +610,40 @@ public class SqliteAuditWriterWriteTests
Assert.Null(row.SourceNode);
}
// ----- C3 hardening: safe enum-parse in MapRow ----- //
// ----- C4 hardening: safe enum-parse in MapRow ----- //
/// <summary>
/// C3 hardening (Task 2.5): a row whose Channel/Kind/Status columns hold
/// an unknown/renamed enum string must NOT fault the read path; it degrades
/// gracefully to the same fallbacks used by <c>AuditRowProjection.Decompose</c>
/// (ApiInbound / InboundRequest / Submitted). The read is exercised via the
/// public <see cref="SqliteAuditWriter.ReadPendingAsync"/> surface which calls
/// the private <c>MapRow</c>.
/// C4 hardening (Task 2.5): a row whose stored <c>Outcome</c> column holds an
/// unknown/renamed enum string must NOT fault the read path; it degrades
/// gracefully to <see cref="AuditOutcome.Success"/> (the safe
/// <see cref="AuditRowProjection.ParseEnum{TEnum}"/> fallback). The read is
/// exercised via the public <see cref="SqliteAuditWriter.ReadPendingAsync"/>
/// surface which calls the private <c>MapRow</c>.
/// </summary>
[Fact]
public async Task ReadPendingAsync_UnknownEnumStrings_DoNotThrow_YieldFallbackValues()
public async Task ReadPendingAsync_UnknownOutcomeString_DoesNotThrow_YieldsFallback()
{
var (writer, dataSource) = CreateWriter(
nameof(ReadPendingAsync_UnknownEnumStrings_DoNotThrow_YieldFallbackValues));
nameof(ReadPendingAsync_UnknownOutcomeString_DoesNotThrow_YieldsFallback));
await using var _ = writer;
var evt = NewEvent();
await writer.WriteAsync(evt);
// Tamper: overwrite the three enum columns with unknown strings that are
// not declared AuditChannel/AuditKind/AuditStatus member names.
// Tamper: overwrite the canonical Outcome column with a string that is not
// a declared AuditOutcome member name.
using (var conn = OpenVerifierConnection(dataSource))
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"UPDATE AuditLog SET Channel = 'ObsoleteChannelV0', " +
"Kind = 'LegacyKindName', Status = 'RenamedStatus99' " +
"WHERE EventId = $id;";
cmd.CommandText = "UPDATE audit_event SET Outcome = 'RenamedOutcome99' WHERE EventId = $id;";
cmd.Parameters.AddWithValue("$id", evt.EventId.ToString());
cmd.ExecuteNonQuery();
}
// Must not throw (previously would throw ArgumentException from Enum.Parse).
// Must not throw (a raw Enum.Parse would throw ArgumentException).
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
var typedRow = row.AsRow();
Assert.Equal(AuditChannel.ApiInbound, typedRow.Channel);
Assert.Equal(AuditKind.InboundRequest, typedRow.Kind);
Assert.Equal(AuditStatus.Submitted, typedRow.Status);
Assert.Equal(AuditOutcome.Success, row.Outcome);
}
}