diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs index 49a6d86..0dc8e6a 100644 --- a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs +++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs @@ -100,6 +100,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable Kind TEXT NOT NULL, CorrelationId TEXT NULL, SourceSiteId TEXT NULL, + SourceNode TEXT NULL, SourceInstanceId TEXT NULL, SourceScript TEXT NULL, Actor TEXT NULL, @@ -144,6 +145,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable // so it is ALTER-ed in here. Nullable with no default — rows written // before this migration read back ParentExecutionId = null. AddColumnIfMissing("ParentExecutionId", "TEXT NULL"); + + // SourceNode stamping: same idempotent upgrade path as ExecutionId / + // ParentExecutionId above. A deployment that already ran the + // ParentExecutionId branch has an auditlog.db with the 22-column + // schema and no SourceNode column; CREATE TABLE IF NOT EXISTS cannot + // add it, so it is ALTER-ed in here. Nullable with no default — rows + // written before this migration read back SourceNode = null. + AddColumnIfMissing("SourceNode", "TEXT NULL"); } /// @@ -270,13 +279,13 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable cmd.CommandText = """ INSERT INTO AuditLog ( EventId, OccurredAtUtc, Channel, Kind, CorrelationId, - SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, + SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, ExecutionId, ParentExecutionId ) VALUES ( $EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId, - $SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target, + $SourceSiteId, $SourceNode, $SourceInstanceId, $SourceScript, $Actor, $Target, $Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail, $RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState, $ExecutionId, $ParentExecutionId @@ -289,6 +298,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable var pKind = cmd.Parameters.Add("$Kind", SqliteType.Text); var pCorrelationId = cmd.Parameters.Add("$CorrelationId", SqliteType.Text); var pSourceSiteId = cmd.Parameters.Add("$SourceSiteId", SqliteType.Text); + var pSourceNode = cmd.Parameters.Add("$SourceNode", SqliteType.Text); var pSourceInstanceId = cmd.Parameters.Add("$SourceInstanceId", SqliteType.Text); var pSourceScript = cmd.Parameters.Add("$SourceScript", SqliteType.Text); var pActor = cmd.Parameters.Add("$Actor", SqliteType.Text); @@ -315,6 +325,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable pKind.Value = e.Kind.ToString(); pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value; pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value; + pSourceNode.Value = (object?)e.SourceNode ?? DBNull.Value; pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value; pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value; pActor.Value = (object?)e.Actor ?? DBNull.Value; @@ -386,7 +397,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable using var cmd = _connection.CreateCommand(); cmd.CommandText = """ SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, - SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, + SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, ExecutionId, ParentExecutionId @@ -435,7 +446,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable using var cmd = _connection.CreateCommand(); cmd.CommandText = """ SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, - SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, + SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, ExecutionId, ParentExecutionId @@ -522,7 +533,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable using var cmd = _connection.CreateCommand(); cmd.CommandText = """ SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, - SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, + SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, ExecutionId, ParentExecutionId @@ -688,22 +699,23 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable Kind = Enum.Parse(reader.GetString(3)), CorrelationId = reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)), SourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5), - SourceInstanceId = reader.IsDBNull(6) ? null : reader.GetString(6), - SourceScript = reader.IsDBNull(7) ? null : reader.GetString(7), - Actor = reader.IsDBNull(8) ? null : reader.GetString(8), - Target = reader.IsDBNull(9) ? null : reader.GetString(9), - Status = Enum.Parse(reader.GetString(10)), - HttpStatus = reader.IsDBNull(11) ? null : reader.GetInt32(11), - DurationMs = reader.IsDBNull(12) ? null : reader.GetInt32(12), - ErrorMessage = reader.IsDBNull(13) ? null : reader.GetString(13), - ErrorDetail = reader.IsDBNull(14) ? null : reader.GetString(14), - RequestSummary = reader.IsDBNull(15) ? null : reader.GetString(15), - ResponseSummary = reader.IsDBNull(16) ? null : reader.GetString(16), - PayloadTruncated = reader.GetInt32(17) != 0, - Extra = reader.IsDBNull(18) ? null : reader.GetString(18), - ForwardState = Enum.Parse(reader.GetString(19)), - ExecutionId = reader.IsDBNull(20) ? null : Guid.Parse(reader.GetString(20)), - ParentExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)), + SourceNode = reader.IsDBNull(6) ? null : reader.GetString(6), + SourceInstanceId = reader.IsDBNull(7) ? null : reader.GetString(7), + SourceScript = reader.IsDBNull(8) ? null : reader.GetString(8), + Actor = reader.IsDBNull(9) ? null : reader.GetString(9), + Target = reader.IsDBNull(10) ? null : reader.GetString(10), + Status = Enum.Parse(reader.GetString(11)), + HttpStatus = reader.IsDBNull(12) ? null : reader.GetInt32(12), + DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13), + ErrorMessage = reader.IsDBNull(14) ? null : reader.GetString(14), + ErrorDetail = reader.IsDBNull(15) ? null : reader.GetString(15), + RequestSummary = reader.IsDBNull(16) ? null : reader.GetString(16), + ResponseSummary = reader.IsDBNull(17) ? null : reader.GetString(17), + PayloadTruncated = reader.GetInt32(18) != 0, + Extra = reader.IsDBNull(19) ? null : reader.GetString(19), + ForwardState = Enum.Parse(reader.GetString(20)), + ExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)), + ParentExecutionId = reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)), }; } diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs index 8f40ebe..26263b9 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs @@ -43,9 +43,9 @@ public class SqliteAuditWriterSchemaTests } [Fact] - public void Opens_Creates_AuditLog_Table_With_22Columns_And_PK_On_EventId() + public void Opens_Creates_AuditLog_Table_With_23Columns_And_PK_On_EventId() { - var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_22Columns_And_PK_On_EventId)); + var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_23Columns_And_PK_On_EventId)); using (writer) { using var connection = OpenVerifierConnection(dataSource); @@ -59,12 +59,12 @@ public class SqliteAuditWriterSchemaTests columns.Add((reader.GetString(1), reader.GetInt32(5))); } - Assert.Equal(22, columns.Count); + Assert.Equal(23, columns.Count); var expected = new[] { "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", - "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", + "SourceSiteId", "SourceNode", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ForwardState", "ExecutionId", "ParentExecutionId", @@ -78,6 +78,19 @@ public class SqliteAuditWriterSchemaTests } } + [Fact] + public void Initialize_creates_AuditLog_with_SourceNode_column() + { + 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() { @@ -377,4 +390,156 @@ public class SqliteAuditWriterSchemaTests Assert.Null(row.ParentExecutionId); } } + + // ----- SourceNode schema-upgrade regression (persistent auditlog.db) ----- // + + /// + /// The pre-SourceNode AuditLog schema — the 22-column CREATE TABLE + /// that HAS ExecutionId + ParentExecutionId but is WITHOUT + /// SourceNode. A deployment that ran the ParentExecutionId branch + /// already has an on-disk auditlog.db in exactly this shape, and + /// CREATE TABLE IF NOT EXISTS is a no-op against it. + /// + private const string OldPreSourceNodeSchema = """ + 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, + ParentExecutionId TEXT NULL, + PRIMARY KEY (EventId) + ); + CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred + ON AuditLog (ForwardState, OccurredAtUtc); + """; + + /// + /// 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. + /// + private static SqliteConnection SeedPreSourceNodeSchemaDatabase(string dataSource) + { + var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = OldPreSourceNodeSchema; + cmd.ExecuteNonQuery(); + return connection; + } + + [Fact] + public async Task Initialize_adds_SourceNode_to_pre_existing_schema() + { + var dataSource = $"file:{nameof(Initialize_adds_SourceNode_to_pre_existing_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")); + + // 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. + await using (var writer = CreateWriterOver(dataSource)) + { + Assert.True( + ColumnExists(seedConnection, "SourceNode"), + "SqliteAuditWriter must ALTER the SourceNode column into a pre-existing AuditLog table."); + + // 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. + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + PayloadTruncated = false, + 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); + } + + // Idempotency: a second writer over the now-upgraded DB must not error + // (the probe sees SourceNode already present and skips the ALTER). + await using (var writerAgain = CreateWriterOver(dataSource)) + { + Assert.True(ColumnExists(seedConnection, "SourceNode")); + } + } + + [Fact] + public async Task WriteAsync_persists_SourceNode_field() + { + var (writer, _) = CreateWriter(nameof(WriteAsync_persists_SourceNode_field)); + await using (writer) + { + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + PayloadTruncated = false, + 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 = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.Notification, + Kind = AuditKind.NotifySend, + Status = AuditStatus.Submitted, + PayloadTruncated = false, + // SourceNode left null + }; + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + var row = Assert.Single(rows); + Assert.Null(row.SourceNode); + } + } }