381 lines
16 KiB
C#
381 lines
16 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using ScadaLink.AuditLog.Site;
|
|
using ScadaLink.Commons.Entities.Audit;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
|
|
namespace ScadaLink.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.
|
|
/// </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,
|
|
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
|
|
return (writer, dataSource);
|
|
}
|
|
|
|
private static SqliteConnection OpenVerifierConnection(string dataSource)
|
|
{
|
|
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
|
|
connection.Open();
|
|
return connection;
|
|
}
|
|
|
|
[Fact]
|
|
public void Opens_Creates_AuditLog_Table_With_22Columns_And_PK_On_EventId()
|
|
{
|
|
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_22Columns_And_PK_On_EventId));
|
|
using (writer)
|
|
{
|
|
using var connection = OpenVerifierConnection(dataSource);
|
|
using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = "PRAGMA table_info(AuditLog);";
|
|
using var reader = cmd.ExecuteReader();
|
|
|
|
var columns = new List<(string Name, int Pk)>();
|
|
while (reader.Read())
|
|
{
|
|
columns.Add((reader.GetString(1), reader.GetInt32(5)));
|
|
}
|
|
|
|
Assert.Equal(22, columns.Count);
|
|
|
|
var expected = new[]
|
|
{
|
|
"EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId",
|
|
"SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target",
|
|
"Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail",
|
|
"RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra",
|
|
"ForwardState", "ExecutionId", "ParentExecutionId",
|
|
};
|
|
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_ForwardState_Occurred_Index()
|
|
{
|
|
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_IX_ForwardState_Occurred_Index));
|
|
using (writer)
|
|
{
|
|
using var connection = OpenVerifierConnection(dataSource);
|
|
using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = "PRAGMA index_list(AuditLog);";
|
|
using var reader = cmd.ExecuteReader();
|
|
|
|
var indexNames = new List<string>();
|
|
while (reader.Read())
|
|
{
|
|
indexNames.Add(reader.GetString(1));
|
|
}
|
|
|
|
Assert.Contains("IX_SiteAuditLog_ForwardState_Occurred", indexNames);
|
|
|
|
// Verify the index columns are ForwardState, OccurredAtUtc in that order.
|
|
using var infoCmd = connection.CreateCommand();
|
|
infoCmd.CommandText = "PRAGMA index_info(IX_SiteAuditLog_ForwardState_Occurred);";
|
|
using var infoReader = infoCmd.ExecuteReader();
|
|
|
|
var indexColumns = new List<string>();
|
|
while (infoReader.Read())
|
|
{
|
|
indexColumns.Add(infoReader.GetString(2));
|
|
}
|
|
|
|
Assert.Equal(new[] { "ForwardState", "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);
|
|
}
|
|
}
|
|
|
|
// ----- ExecutionId schema-upgrade regression (persistent auditlog.db) ----- //
|
|
|
|
/// <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.
|
|
/// </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,
|
|
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 = new AuditEvent
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
OccurredAtUtc = DateTime.UtcNow,
|
|
Channel = AuditChannel.ApiOutbound,
|
|
Kind = AuditKind.ApiCall,
|
|
Status = AuditStatus.Delivered,
|
|
PayloadTruncated = false,
|
|
ExecutionId = executionId,
|
|
};
|
|
await writer.WriteAsync(evt);
|
|
|
|
var rows = await writer.ReadPendingAsync(limit: 10);
|
|
var row = Assert.Single(rows);
|
|
Assert.Equal(executionId, row.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 = new AuditEvent
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
OccurredAtUtc = DateTime.UtcNow,
|
|
Channel = AuditChannel.ApiOutbound,
|
|
Kind = AuditKind.ApiCall,
|
|
Status = AuditStatus.Delivered,
|
|
PayloadTruncated = false,
|
|
ExecutionId = executionId,
|
|
ParentExecutionId = parentExecutionId,
|
|
};
|
|
await writer.WriteAsync(evt);
|
|
|
|
var rows = await writer.ReadPendingAsync(limit: 10);
|
|
var row = Assert.Single(rows);
|
|
Assert.Equal(executionId, row.ExecutionId);
|
|
Assert.Equal(parentExecutionId, row.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 = new AuditEvent
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
OccurredAtUtc = DateTime.UtcNow,
|
|
Channel = AuditChannel.Notification,
|
|
Kind = AuditKind.NotifySend,
|
|
Status = AuditStatus.Submitted,
|
|
PayloadTruncated = false,
|
|
// ParentExecutionId left null
|
|
};
|
|
await writer.WriteAsync(evt);
|
|
|
|
var rows = await writer.ReadPendingAsync(limit: 10);
|
|
var row = Assert.Single(rows);
|
|
Assert.Null(row.ParentExecutionId);
|
|
}
|
|
}
|
|
}
|