fix(auditlog): evolve existing site auditlog.db schema for ExecutionId
This commit is contained in:
@@ -2,6 +2,8 @@ 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;
|
||||
|
||||
@@ -125,4 +127,122 @@ public class SqliteAuditWriterSchemaTests
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user