Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs
T

388 lines
16 KiB
C#

using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// 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
{
/// <summary>
/// Each test uses a unique shared-cache in-memory database. The
/// "Mode=Memory;Cache=Shared" syntax lets two SqliteConnections see the same
/// in-memory store as long as both use the same Data Source name.
/// </summary>
private static (SqliteAuditWriter writer, string dataSource) CreateWriter(string testName)
{
var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared";
var options = new SqliteAuditWriterOptions
{
DatabasePath = dataSource,
};
// The writer uses raw "Data Source={path}" by appending Cache=Shared. Override
// by passing the full connection string via the connectionStringOverride hook.
var writer = new SqliteAuditWriter(
Options.Create(options),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
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");
connection.Open();
return connection;
}
private static List<string> ColumnNames(SqliteConnection connection, string table)
{
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(audit_event);";
using var reader = cmd.ExecuteReader();
var columns = new List<(string Name, int Pk)>();
while (reader.Read())
{
columns.Add((reader.GetString(1), reader.GetInt32(5)));
}
// The 10 canonical ZB.MOM.WW.Audit.AuditEvent fields, stored directly.
var expected = new[]
{
"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.
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_audit_forward_state_Sidecar_Table_With_Expected_Columns()
{
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 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>();
while (reader.Read())
{
indexNames.Add(reader.GetString(1));
}
Assert.Contains("IX_fwd", indexNames);
// Verify the index columns are ForwardState, IsCachedKind, OccurredAtUtc
// in that order.
using var infoCmd = connection.CreateCommand();
infoCmd.CommandText = "PRAGMA index_info(IX_fwd);";
using var infoReader = infoCmd.ExecuteReader();
var indexColumns = new List<string>();
while (infoReader.Read())
{
indexColumns.Add(infoReader.GetString(2));
}
Assert.Equal(new[] { "ForwardState", "IsCachedKind", "OccurredAtUtc" }, indexColumns);
}
}
[Fact]
public void PRAGMA_auto_vacuum_Is_INCREMENTAL()
{
var (writer, dataSource) = CreateWriter(nameof(PRAGMA_auto_vacuum_Is_INCREMENTAL));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA auto_vacuum;";
var value = Convert.ToInt32(cmd.ExecuteScalar());
// INCREMENTAL = 2 (0 = NONE, 1 = FULL, 2 = INCREMENTAL).
Assert.Equal(2, value);
}
}
// ----- C4 ephemeral in-place reset: old single-table schema is dropped ----- //
/// <summary>
/// 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 OldSingleTableSchema = """
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,
SourceNode 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,
ParentExecutionId 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 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 SeedOldSingleTableDatabase(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
using var cmd = connection.CreateCommand();
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 Opening_Over_PreExisting_OldSingleTable_Db_Drops_It_And_Creates_Two_Table_Schema()
{
var dataSource = $"file:{nameof(Opening_Over_PreExisting_OldSingleTable_Db_Drops_It_And_Creates_Two_Table_Schema)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// 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 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.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"));
// 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);
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(evt.EventId, row.EventId);
}
// 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(TableExists(seedConnection, "audit_event"));
Assert.True(TableExists(seedConnection, "audit_forward_state"));
}
}
// ----- Canonical / sidecar field persistence ----- //
[Fact]
public async Task WriteAsync_persists_SourceNode_field()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_persists_SourceNode_field));
await using (writer)
{
var evt = ScadaBridgeAuditEventFactory.Create(
eventId: Guid.NewGuid(),
occurredAtUtc: DateTime.UtcNow,
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
sourceNode: "node-a");
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal("node-a", row.SourceNode);
}
}
[Fact]
public async Task WriteAsync_persists_null_SourceNode()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_persists_null_SourceNode));
await using (writer)
{
var evt = ScadaBridgeAuditEventFactory.Create(
eventId: Guid.NewGuid(),
occurredAtUtc: DateTime.UtcNow,
channel: AuditChannel.Notification,
kind: AuditKind.NotifySend,
status: AuditStatus.Submitted);
// SourceNode 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.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);
}
}
}