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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user