using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; using ZB.MOM.WW.Audit; using ZB.MOM.WW.Auth.ApiKeys.Sqlite; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Security.Audit; using ZB.MOM.WW.MxGateway.Tests.Security.Authentication; namespace ZB.MOM.WW.MxGateway.Tests.Security.Audit; /// /// Tests the bounded, asynchronous audit path: (enqueue-only /// once a drain is attached, drops rather than blocks when the channel is full, writes through /// whenever nothing is draining) and (batched drain, poison-batch /// isolation, one-time table bootstrap, retention sweep). The channel makes the already-documented /// best-effort audit contract explicit and bounded: a partially denied bulk RPC no longer pays a /// SQLite round-trip per denied tag. /// public sealed class ChannelAuditWriterTests : IDisposable { private readonly List _tempDirectories = []; /// /// With a drain attached, only enqueues — the /// sink is not touched until the drain runs, and the event arrives once it does. /// /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_WithDrainAttached_EnqueuesOnlyAndReachesSinkAfterDrain() { CountingAuditSink sink = new(); (ChannelAuditWriter writer, AuditDrainService drain) = CreateWriterAndDrain(sink); writer.AttachDrain(); await writer.WriteAsync(MakeEvent("constraint-denied"), CancellationToken.None); // Enqueue-only: nothing has reached the sink yet. Assert.Equal(0, sink.InsertBatchCalls); Assert.Empty(sink.Events); int drained = await drain.DrainPendingAsync(CancellationToken.None); Assert.Equal(1, drained); Assert.Equal(1, sink.InsertBatchCalls); Assert.Equal("constraint-denied", Assert.Single(sink.Events).Action); } /// /// A drain pass batches up to events into one /// sink call, so a partially denied bulk RPC costs a handful of transactions, not one per tag. /// /// A task that represents the asynchronous operation. [Fact] public async Task DrainPendingAsync_BatchesEventsIntoBoundedInsertCalls() { CountingAuditSink sink = new(); (ChannelAuditWriter writer, AuditDrainService drain) = CreateWriterAndDrain(sink); writer.AttachDrain(); const int eventCount = 150; for (int index = 0; index < eventCount; index++) { await writer.WriteAsync(MakeEvent($"denied-{index}"), CancellationToken.None); } int drained = await drain.DrainPendingAsync(CancellationToken.None); Assert.Equal(eventCount, drained); Assert.Equal(eventCount, sink.Events.Count); // 150 events at a batch size of 64 → 3 transactions (64 + 64 + 22), not 150. Assert.Equal(3, sink.InsertBatchCalls); Assert.All(sink.BatchSizes, size => Assert.True(size <= AuditDrainService.MaxBatchSize)); } /// /// A batch that will not commit is retried one event at a time, so a single unwritable row /// costs only itself instead of taking the up-to-63 good events sharing its transaction. /// /// A task that represents the asynchronous operation. [Fact] public async Task DrainPendingAsync_WhenBatchFails_RetriesIndividuallyAndKeepsSurvivors() { CountingAuditSink sink = new() { PoisonAction = "poison" }; (ChannelAuditWriter writer, AuditDrainService drain) = CreateWriterAndDrain(sink); writer.AttachDrain(); await writer.WriteAsync(MakeEvent("good-1"), CancellationToken.None); await writer.WriteAsync(MakeEvent("poison"), CancellationToken.None); await writer.WriteAsync(MakeEvent("good-2"), CancellationToken.None); int persisted = await drain.DrainPendingAsync(CancellationToken.None); // The batch commit fails on the poison event; the per-event retry still lands both others. Assert.Equal(2, persisted); Assert.Equal(["good-1", "good-2"], sink.Events.Select(auditEvent => auditEvent.Action)); } /// /// When the bounded channel is full the write is dropped rather than blocking the caller, /// and the drop is counted. A stalled or slow audit database must never stall an RPC. /// /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_WhenChannelFull_DropsWriteAndCountsItWithoutBlocking() { CountingAuditSink sink = new(); (ChannelAuditWriter writer, _) = CreateWriterAndDrain(sink); writer.AttachDrain(); const int overflow = 32; for (int index = 0; index < ChannelAuditWriter.ChannelCapacity + overflow; index++) { // Every call must complete synchronously: the channel never blocks a producer. Task write = writer.WriteAsync(MakeEvent($"denied-{index}"), CancellationToken.None); Assert.True(write.IsCompletedSuccessfully); await write; } Assert.Equal(overflow, writer.DroppedCount); // The channel still holds exactly its capacity; nothing reached the sink (no drain ran). Assert.Equal(0, sink.InsertBatchCalls); } /// /// With no drain attached (the apikey CLI, DI-only tests, post-shutdown) the writer /// writes through to the sink instead of enqueueing into a channel nobody will ever read. /// /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_WithNoDrainAttached_WritesThroughToSink() { CountingAuditSink sink = new(); (ChannelAuditWriter writer, _) = CreateWriterAndDrain(sink); await writer.WriteAsync(MakeEvent("dashboard-create-key"), CancellationToken.None); Assert.Equal("dashboard-create-key", Assert.Single(sink.Events).Action); } /// /// A completed channel has no future reader, so an attached writer must write through rather /// than discard. This is the re-attach-after-shutdown footgun: enqueueing would lose 100% of /// audit silently. /// /// A task that represents the asynchronous operation. [Fact] public async Task WriteAsync_AfterChannelCompleted_WritesThroughInsteadOfDiscarding() { CountingAuditSink sink = new(); (ChannelAuditWriter writer, _) = CreateWriterAndDrain(sink); writer.AttachDrain(); writer.CompleteWriting(); await writer.WriteAsync(MakeEvent("constraint-denied"), CancellationToken.None); Assert.Equal("constraint-denied", Assert.Single(sink.Events).Action); Assert.Equal(0, writer.DroppedCount); } /// /// The retention sweep deletes rows older than AuditRetentionDays, measured from the /// injected clock, and runs once at startup after the one-time table bootstrap. /// /// A task that represents the asynchronous operation. [Fact] public async Task StartAsync_BootstrapsTableThenSweepsRetentionAtConfiguredCutoff() { CountingAuditSink sink = new(); FakeTimeProvider clock = new(new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero)); (_, AuditDrainService drain) = CreateWriterAndDrain( sink, new SecurityOptions { AuditRetentionDays = 30 }, clock); await drain.StartAsync(CancellationToken.None); await drain.StopAsync(CancellationToken.None); Assert.Equal(1, sink.EnsureInitializedCalls); Assert.Equal(new DateTimeOffset(2026, 7, 16, 12, 0, 0, TimeSpan.Zero), Assert.Single(sink.DeleteCutoffs)); } /// /// The sweep compares instants, not stored text, and never deletes a row it cannot date. /// /// A task that represents the asynchronous operation. [Fact] public async Task SqliteStore_DeleteOlderThan_ComparesInstantsAndKeepsUnparseableRows() { (SqliteCanonicalAuditStore store, AuthSqliteConnectionFactory factory) = CreateStore(); DateTimeOffset cutoff = new(2026, 5, 17, 12, 0, 0, TimeSpan.Zero); await store.InsertBatchAsync( [ MakeEvent("swept", new DateTimeOffset(2026, 5, 17, 11, 0, 0, TimeSpan.Zero)), MakeEvent("kept-fresh", new DateTimeOffset(2026, 5, 18, 0, 0, 0, TimeSpan.Zero)), ], CancellationToken.None); // Both discriminating rows are written as raw text, because AuditEvent.OccurredAtUtc // normalizes to UTC and so cannot express them. They stand for audit that reached the // table any other way — a repair script, an older schema, a future producer. // // 09:00 at -05:00 is 14:00 UTC, two hours AFTER the cutoff, yet its text sorts BEFORE // "2026-05-17T12:00:00.0000000+00:00". A lexicographic sweep deletes it; comparing // instants keeps it. await InsertRawRowAsync(factory, "kept-offset", "2026-05-17T09:00:00.1234567-05:00"); // Undateable audit is kept, never guessed at. This text sorts BELOW the cutoff, so a // lexicographic sweep deletes it, while datetime() yields NULL and leaves it alone. await InsertRawRowAsync(factory, "kept-unparseable", "0000-not-a-timestamp"); int deleted = await store.DeleteOlderThanAsync(cutoff, CancellationToken.None); Assert.Equal(1, deleted); Assert.Equal( ["kept-fresh", "kept-offset", "kept-unparseable"], (await ListActionsAsync(factory)).OrderBy(action => action, StringComparer.Ordinal)); } /// /// The sweep above deliberately preserves rows it cannot date, so the dashboard's recent-audit /// view is guaranteed to meet one eventually. Reading must therefore be defensive: the /// undateable row is reported at with every other column /// intact, rather than one bad row throwing the whole page away. /// /// A task that represents the asynchronous operation. [Fact] public async Task SqliteStore_ListRecent_WithUnparseableRow_ReportsMinValueInsteadOfThrowing() { (SqliteCanonicalAuditStore store, AuthSqliteConnectionFactory factory) = CreateStore(); DateTimeOffset occurred = new(2026, 5, 18, 0, 0, 0, TimeSpan.Zero); await store.InsertAsync(MakeEvent("dateable", occurred), CancellationToken.None); await InsertRawRowAsync(factory, "undateable", "0000-not-a-timestamp"); IReadOnlyList recent = await store.ListRecentAsync(10, CancellationToken.None); Assert.Equal(2, recent.Count); Assert.Equal(occurred, recent.Single(auditEvent => auditEvent.Action == "dateable").OccurredAtUtc); AuditEvent undateable = recent.Single(auditEvent => auditEvent.Action == "undateable"); Assert.Equal(DateTimeOffset.MinValue, undateable.OccurredAtUtc); Assert.Equal("operator01", undateable.Actor); Assert.Equal(AuditOutcome.Denied, undateable.Outcome); } private static (ChannelAuditWriter Writer, AuditDrainService Drain) CreateWriterAndDrain( IAuditEventSink sink, SecurityOptions? security = null, TimeProvider? timeProvider = null) { ChannelAuditWriter writer = new( new CanonicalAuditWriter(sink, NullLogger.Instance), NullLogger.Instance); AuditDrainService drain = new( writer, sink, security ?? new SecurityOptions(), timeProvider ?? TimeProvider.System, NullLogger.Instance); return (writer, drain); } private (SqliteCanonicalAuditStore Store, AuthSqliteConnectionFactory Factory) CreateStore() { TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-channel-audit"); _tempDirectories.Add(directory); AuthSqliteConnectionFactory factory = new(directory.DatabasePath()); return (new SqliteCanonicalAuditStore(factory), factory); } // Writes an audit row whose occurred_at_utc bypasses the store's DateTimeOffset formatting, // so the sweep can be shown to leave undateable audit alone. private static async Task InsertRawRowAsync( AuthSqliteConnectionFactory factory, string action, string occurredAtUtc) { await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ INSERT INTO audit_event (event_id, occurred_at_utc, actor, action, outcome) VALUES ($event_id, $occurred_at_utc, 'operator01', $action, 'Denied'); """; command.Parameters.AddWithValue("$event_id", Guid.NewGuid().ToString()); command.Parameters.AddWithValue("$occurred_at_utc", occurredAtUtc); command.Parameters.AddWithValue("$action", action); await command.ExecuteNonQueryAsync(CancellationToken.None); } // Reads actions straight from SQL so the sweep assertion depends on the DELETE alone, with no // opinion from the store's read path about how an undateable row is surfaced. private static async Task> ListActionsAsync(AuthSqliteConnectionFactory factory) { await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = "SELECT action FROM audit_event;"; List actions = []; await using SqliteDataReader reader = await command.ExecuteReaderAsync(CancellationToken.None); while (await reader.ReadAsync(CancellationToken.None)) { actions.Add(reader.GetString(0)); } return actions; } private static AuditEvent MakeEvent(string action, DateTimeOffset? occurredAtUtc = null) => new() { EventId = Guid.NewGuid(), OccurredAtUtc = occurredAtUtc ?? DateTimeOffset.UtcNow, Actor = "operator01", Action = action, Outcome = AuditOutcome.Denied, Category = "ApiKey", }; /// Clears SQLite pools and deletes every temporary directory created by this test. public void Dispose() { foreach (TempDatabaseDirectory directory in _tempDirectories) { directory.Dispose(); } _tempDirectories.Clear(); } /// /// In-memory that records every call, so a test can prove the /// write path did NOT touch the sink and that the drain batched what it did write. Setting /// makes any write containing that action fail, modelling a row /// the store refuses. /// private sealed class CountingAuditSink : IAuditEventSink { private readonly Lock _gate = new(); /// Gets the action whose presence makes a write fail, or null to accept everything. public string? PoisonAction { get; init; } /// Gets the events handed to the sink, in the order they were written. public List Events { get; } = []; /// Gets the size of each batch the sink accepted. public List BatchSizes { get; } = []; /// Gets the cutoffs the sink was asked to delete below. public List DeleteCutoffs { get; } = []; /// Gets the number of accepted calls. public int InsertBatchCalls => BatchSizes.Count; /// Gets the number of calls. public int EnsureInitializedCalls { get; private set; } /// public Task EnsureInitializedAsync(CancellationToken cancellationToken) { lock (_gate) { EnsureInitializedCalls++; } return Task.CompletedTask; } /// public Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken) => InsertBatchAsync([auditEvent], cancellationToken); /// public Task InsertBatchAsync(IReadOnlyList auditEvents, CancellationToken cancellationToken) { if (PoisonAction is not null && auditEvents.Any(auditEvent => auditEvent.Action == PoisonAction)) { return Task.FromException( new InvalidOperationException($"Refused a write of {auditEvents.Count} audit events.")); } lock (_gate) { BatchSizes.Add(auditEvents.Count); Events.AddRange(auditEvents); } return Task.CompletedTask; } /// public Task DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken) { lock (_gate) { DeleteCutoffs.Add(cutoffUtc); } return Task.FromResult(0); } } }