183 lines
9.9 KiB
C#
183 lines
9.9 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>
|
|
/// Flow Debugger (design 2026-09-05 §5.4): <see cref="SqliteAuditWriter.ReadByExecutionIdsAsync"/>
|
|
/// matches on ExecutionId OR ParentExecutionId, honours the since bound, orders by
|
|
/// (OccurredAtUtc, EventId), caps the row count, and is independent of forwarding state.
|
|
/// </summary>
|
|
public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
|
{
|
|
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"audit-readbyexec-{Guid.NewGuid():N}.db");
|
|
private static readonly DateTime T0 = new(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc);
|
|
|
|
public void Dispose()
|
|
{
|
|
if (File.Exists(_dbPath)) { try { File.Delete(_dbPath); } catch { /* best-effort */ } }
|
|
}
|
|
|
|
private SqliteAuditWriter CreateWriter() => new(
|
|
Options.Create(new SqliteAuditWriterOptions { DatabasePath = _dbPath }),
|
|
NullLogger<SqliteAuditWriter>.Instance,
|
|
new FakeNodeIdentityProvider());
|
|
|
|
private static AuditEvent Row(DateTime at, Guid? exec, Guid? parent, AuditKind kind = AuditKind.ScriptRun) =>
|
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, kind, AuditStatus.Delivered,
|
|
occurredAtUtc: at, executionId: exec, parentExecutionId: parent, sourceSiteId: "site-a");
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(64)]
|
|
public async Task TraceReadPlan_UsesExecutionIndexesInsteadOfRetainedStoreScan(int idCount)
|
|
{
|
|
await using var writer = CreateWriter();
|
|
using var connection = new SqliteConnection($"Data Source={_dbPath}");
|
|
await connection.OpenAsync();
|
|
using var command = connection.CreateCommand();
|
|
var names = Enumerable.Range(0, idCount).Select(i => $"$e{i}").ToArray();
|
|
command.CommandText = "EXPLAIN QUERY PLAN " + SqliteAuditWriter.BuildTraceReadSql(string.Join(", ", names));
|
|
foreach (var name in names) command.Parameters.AddWithValue(name, Guid.NewGuid().ToString("D"));
|
|
command.Parameters.AddWithValue("$since", DateTime.MinValue.ToString("o"));
|
|
command.Parameters.AddWithValue("$after", DBNull.Value);
|
|
command.Parameters.AddWithValue("$limit", 2000);
|
|
var plan = new List<string>();
|
|
using var reader = await command.ExecuteReaderAsync();
|
|
while (await reader.ReadAsync()) plan.Add(reader.GetString(3));
|
|
Assert.Contains(plan, p => p.Contains("SEARCH audit_event USING INDEX IX_audit_event_execution", StringComparison.Ordinal));
|
|
Assert.Contains(plan, p => p.Contains("SEARCH audit_event USING INDEX IX_audit_event_parent_execution", StringComparison.Ordinal));
|
|
Assert.DoesNotContain(plan, p => p.StartsWith("SCAN audit_event", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LegacyMalformedDetails_DoNotBreakStartupOrTraceReads()
|
|
{
|
|
var root = Guid.NewGuid();
|
|
var row = Row(T0, root, null);
|
|
await using (var writer = CreateWriter()) await writer.WriteAsync(row);
|
|
using (var connection = new SqliteConnection($"Data Source={_dbPath}"))
|
|
{
|
|
await connection.OpenAsync();
|
|
using var command = connection.CreateCommand();
|
|
// Simulate a pre-upgrade file, including malformed payloads from an older writer.
|
|
command.CommandText = "DROP INDEX IF EXISTS IX_audit_event_execution; DROP INDEX IF EXISTS IX_audit_event_parent_execution; "
|
|
+ "INSERT INTO audit_event(EventId,OccurredAtUtc,Actor,Action,Outcome,DetailsJson) VALUES ($id,$at,'legacy','legacy','Success','{broken');";
|
|
command.Parameters.AddWithValue("$id", Guid.NewGuid().ToString("D"));
|
|
command.Parameters.AddWithValue("$at", T0.ToString("o"));
|
|
await command.ExecuteNonQueryAsync();
|
|
}
|
|
await using var reopened = CreateWriter();
|
|
var result = await reopened.ReadByExecutionIdsAsync([root], DateTime.MinValue, 2000);
|
|
Assert.Equal(row.EventId, Assert.Single(result).EventId);
|
|
// Index initialization remains idempotent on the upgraded database.
|
|
await using var reopenedAgain = CreateWriter();
|
|
Assert.Equal(row.EventId, Assert.Single(await reopenedAgain.ReadByExecutionIdsAsync([root], DateTime.MinValue, 2000)).EventId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cursor_PagesEqualTimestampsWithoutRepeatingOrSkippingRows()
|
|
{
|
|
var root = Guid.NewGuid();
|
|
await using var writer = CreateWriter();
|
|
var rows = Enumerable.Range(0, 7).Select(_ => Row(T0, root, null)).ToArray();
|
|
foreach (var row in rows) await writer.WriteAsync(row);
|
|
var first = await writer.ReadByExecutionIdsAsync([root], T0, 3);
|
|
// Reflection lets the pre-fix implementation demonstrate its repeated oldest page.
|
|
var method = typeof(SqliteAuditWriter).GetMethod(nameof(SqliteAuditWriter.ReadByExecutionIdsAsync))!;
|
|
object[] args = method.GetParameters().Length == 5
|
|
? [new[] { root }, T0, 3, CancellationToken.None, first[^1].EventId.ToString("D")]
|
|
: [new[] { root }, T0, 3, CancellationToken.None];
|
|
var second = await (Task<IReadOnlyList<AuditEvent>>)method.Invoke(writer, args)!;
|
|
Assert.Equal(6, first.Concat(second).Select(e => e.EventId).Distinct().Count());
|
|
Assert.Equal(rows.OrderBy(e => e.EventId.ToString("D"), StringComparer.Ordinal).Take(6).Select(e => e.EventId),
|
|
first.Concat(second).Select(e => e.EventId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MatchesExecutionId_OrParentExecutionId_OrderedAndBounded()
|
|
{
|
|
var root = Guid.NewGuid();
|
|
var child = Guid.NewGuid();
|
|
var unrelated = Guid.NewGuid();
|
|
var rows = new[]
|
|
{
|
|
Row(T0.AddSeconds(3), child, root, AuditKind.AttributeWrite), // parent match
|
|
Row(T0.AddSeconds(1), root, null), // exec match
|
|
Row(T0.AddSeconds(2), child, root), // parent match
|
|
Row(T0.AddSeconds(4), unrelated, null), // no match
|
|
Row(T0.AddSeconds(-60), root, null), // before since
|
|
};
|
|
|
|
await using (var writer = CreateWriter())
|
|
{
|
|
foreach (var r in rows) await writer.WriteAsync(r);
|
|
|
|
var hits = await writer.ReadByExecutionIdsAsync([root], T0, limit: 100);
|
|
Assert.Equal(3, hits.Count);
|
|
Assert.Equal(rows[1].EventId, hits[0].EventId);
|
|
Assert.Equal(rows[2].EventId, hits[1].EventId);
|
|
Assert.Equal(rows[0].EventId, hits[2].EventId);
|
|
Assert.Equal(rows[1], hits[0]);
|
|
|
|
// Bounded.
|
|
var two = await writer.ReadByExecutionIdsAsync([root], T0, limit: 2);
|
|
Assert.Equal(2, two.Count);
|
|
|
|
// Forwarding state is irrelevant: mark everything Reconciled, still served.
|
|
await writer.MarkReconciledAsync(rows.Select(r => r.EventId).ToList());
|
|
Assert.Equal(3, (await writer.ReadByExecutionIdsAsync([root], T0, limit: 100)).Count);
|
|
|
|
// Empty id set → empty; multiple ids union.
|
|
Assert.Empty(await writer.ReadByExecutionIdsAsync(Array.Empty<Guid>(), T0, 10));
|
|
Assert.Equal(4, (await writer.ReadByExecutionIdsAsync([root, unrelated], T0, 100)).Count);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Outbound-calls plan (2026-09-06, D6): the site pull filters by execution id ONLY — an
|
|
/// ApiOutbound row (or any other channel) stored under the execution id is served alongside
|
|
/// the ScriptTrace rows, in time order. Pins that no channel predicate creeps in.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ServesOutboundRows_NoChannelPredicate()
|
|
{
|
|
var root = Guid.NewGuid();
|
|
var siteRun = Guid.NewGuid();
|
|
var run = Row(T0.AddSeconds(1), siteRun, root);
|
|
var apiCall = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted,
|
|
occurredAtUtc: T0.AddSeconds(2), target: "Delmia.AckRecipe", executionId: siteRun, parentExecutionId: root,
|
|
sourceSiteId: "site-a", correlationId: Guid.NewGuid());
|
|
var dbWrite = ScadaBridgeAuditEventFactory.Create(AuditChannel.DbOutbound, AuditKind.DbWrite, AuditStatus.Delivered,
|
|
occurredAtUtc: T0.AddSeconds(3), target: "MesDb", executionId: siteRun, parentExecutionId: root, sourceSiteId: "site-a");
|
|
var notify = ScadaBridgeAuditEventFactory.Create(AuditChannel.Notification, AuditKind.NotifySend, AuditStatus.Submitted,
|
|
occurredAtUtc: T0.AddSeconds(4), target: "Planners", executionId: siteRun, parentExecutionId: root,
|
|
sourceSiteId: "site-a", correlationId: Guid.NewGuid());
|
|
|
|
await using var writer = CreateWriter();
|
|
foreach (var r in new[] { notify, apiCall, run, dbWrite }) await writer.WriteAsync(r);
|
|
|
|
var hits = await writer.ReadByExecutionIdsAsync([siteRun], T0, limit: 100);
|
|
Assert.Equal(
|
|
new[] { run.EventId, apiCall.EventId, dbWrite.EventId, notify.EventId },
|
|
hits.Select(h => h.EventId).ToArray());
|
|
Assert.Equal(apiCall, hits[1]);
|
|
Assert.Equal(AuditChannel.ApiOutbound, AuditRowProjection.Decompose(hits[1]).Channel);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Guards_LimitAndIdCap()
|
|
{
|
|
await using var writer = CreateWriter();
|
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => writer.ReadByExecutionIdsAsync([Guid.NewGuid()], T0, 0));
|
|
var tooMany = Enumerable.Range(0, SqliteAuditWriter.MaxExecutionIdsPerRead + 1).Select(_ => Guid.NewGuid()).ToList();
|
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => writer.ReadByExecutionIdsAsync(tooMany, T0, 10));
|
|
}
|
|
}
|