using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.Audit; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.Auth.ApiKeys.Sqlite; 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 Task 2.3 canonical audit plumbing: the gateway-owned /// (round-trips canonical /// s through the new audit_event table), the best-effort /// , and the /// adapter that maps the library's onto the canonical model /// and back (so the dashboard recent-audit view keeps working). All against a real /// temporary SQLite database, sharing the library's connection factory. /// public sealed class CanonicalAuditStoreAndAdapterTests : IDisposable { private readonly List _tempDirectories = []; /// A canonical event with all fields populated round-trips through the store. [Fact] public async Task Store_InsertThenListRecent_RoundTripsAllFields() { SqliteCanonicalAuditStore store = CreateStore(); Guid eventId = Guid.NewGuid(); Guid correlationId = Guid.NewGuid(); DateTimeOffset occurred = new(2026, 6, 1, 12, 34, 56, TimeSpan.Zero); AuditEvent original = new() { EventId = eventId, OccurredAtUtc = occurred, Actor = "operator01", Action = "dashboard-create-key", Outcome = AuditOutcome.Success, Category = "ApiKey", Target = "operator01", SourceNode = "127.0.0.1", CorrelationId = correlationId, DetailsJson = "{\"detail\":\"created\"}", }; await store.InsertAsync(original, CancellationToken.None); IReadOnlyList recent = await store.ListRecentAsync(10, CancellationToken.None); AuditEvent persisted = Assert.Single(recent); Assert.Equal(eventId, persisted.EventId); Assert.Equal(occurred, persisted.OccurredAtUtc); Assert.Equal("operator01", persisted.Actor); Assert.Equal("dashboard-create-key", persisted.Action); Assert.Equal(AuditOutcome.Success, persisted.Outcome); Assert.Equal("ApiKey", persisted.Category); Assert.Equal("operator01", persisted.Target); Assert.Equal("127.0.0.1", persisted.SourceNode); Assert.Equal(correlationId, persisted.CorrelationId); Assert.Equal("{\"detail\":\"created\"}", persisted.DetailsJson); } /// Nullable canonical fields round-trip as null, not empty string. [Fact] public async Task Store_InsertWithNullOptionalFields_RoundTripsAsNull() { SqliteCanonicalAuditStore store = CreateStore(); AuditEvent original = new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, Actor = "system", Action = "init-db", Outcome = AuditOutcome.Success, }; await store.InsertAsync(original, CancellationToken.None); AuditEvent persisted = Assert.Single(await store.ListRecentAsync(10, CancellationToken.None)); Assert.Null(persisted.Category); Assert.Null(persisted.Target); Assert.Null(persisted.SourceNode); Assert.Null(persisted.CorrelationId); Assert.Null(persisted.DetailsJson); } /// ListRecent returns newest-first. [Fact] public async Task Store_ListRecent_ReturnsNewestFirst() { SqliteCanonicalAuditStore store = CreateStore(); await store.InsertAsync(MakeEvent("first"), CancellationToken.None); await store.InsertAsync(MakeEvent("second"), CancellationToken.None); await store.InsertAsync(MakeEvent("third"), CancellationToken.None); IReadOnlyList recent = await store.ListRecentAsync(10, CancellationToken.None); Assert.Equal(["third", "second", "first"], recent.Select(e => e.Action)); } /// The writer is best-effort: a faulting store does not surface to the caller. [Fact] public async Task Writer_WhenStoreFails_DoesNotThrow() { // Point the connection factory at a path that cannot be created (a file used as a // directory) so OpenConnectionAsync/insert fails; the writer must swallow it. TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-canonical-audit-fail"); _tempDirectories.Add(directory); string filePath = directory.DatabasePath("blocker"); File.WriteAllText(filePath, "not a directory"); // Nested path under a regular file → directory creation / open fails. AuthSqliteConnectionFactory factory = new(Path.Combine(filePath, "audit.db")); CanonicalAuditWriter writer = new( new SqliteCanonicalAuditStore(factory), NullLogger.Instance); // Must not throw. await writer.WriteAsync(MakeEvent("anything"), CancellationToken.None); } /// /// A library event with a KeyId maps to a canonical Success event under category ApiKey, /// and the adapter maps it back to the original entry for the dashboard view. /// [Fact] public async Task Adapter_KeyedEvent_RoundTripsThroughCanonicalStore() { (CanonicalForwardingApiKeyAuditStore adapter, SqliteCanonicalAuditStore store) = CreateAdapter(); DateTimeOffset created = new(2026, 6, 1, 9, 0, 0, TimeSpan.Zero); await adapter.AppendAsync( new ApiKeyAuditEntry( KeyId: "operator01", EventType: "create-key", RemoteAddress: "10.0.0.5", CreatedUtc: created, Details: "created"), CancellationToken.None); // Canonical persisted form. AuditEvent canonical = Assert.Single(await store.ListRecentAsync(10, CancellationToken.None)); Assert.Equal("operator01", canonical.Actor); Assert.Equal("create-key", canonical.Action); Assert.Equal(AuditOutcome.Success, canonical.Outcome); Assert.Equal(CanonicalForwardingApiKeyAuditStore.ApiKeyCategory, canonical.Category); Assert.Equal("operator01", canonical.Target); Assert.Equal("10.0.0.5", canonical.SourceNode); Assert.Equal("{\"detail\":\"created\"}", canonical.DetailsJson); // Dashboard-facing form via the adapter's ListRecentAsync. ApiKeyAuditEntry mappedBack = Assert.Single(await adapter.ListRecentAsync(10, CancellationToken.None)); Assert.Equal("operator01", mappedBack.KeyId); Assert.Equal("create-key", mappedBack.EventType); Assert.Equal("10.0.0.5", mappedBack.RemoteAddress); Assert.Equal(created, mappedBack.CreatedUtc); Assert.Equal("created", mappedBack.Details); } /// The keyless library init-db event maps to Actor "system" and back to a null KeyId. [Fact] public async Task Adapter_InitDbKeylessEvent_MapsToSystemActor() { (CanonicalForwardingApiKeyAuditStore adapter, SqliteCanonicalAuditStore store) = CreateAdapter(); await adapter.AppendAsync( new ApiKeyAuditEntry( KeyId: null, EventType: "init-db", RemoteAddress: null, CreatedUtc: DateTimeOffset.UtcNow, Details: null), CancellationToken.None); AuditEvent canonical = Assert.Single(await store.ListRecentAsync(10, CancellationToken.None)); Assert.Equal("system", canonical.Actor); Assert.Equal(AuditOutcome.Success, canonical.Outcome); Assert.Null(canonical.DetailsJson); ApiKeyAuditEntry mappedBack = Assert.Single(await adapter.ListRecentAsync(10, CancellationToken.None)); Assert.Null(mappedBack.KeyId); Assert.Equal("init-db", mappedBack.EventType); Assert.Null(mappedBack.Details); } /// Any other keyless library event maps to Actor "cli" and back to a null KeyId. [Fact] public async Task Adapter_OtherKeylessEvent_MapsToCliActor() { (CanonicalForwardingApiKeyAuditStore adapter, SqliteCanonicalAuditStore store) = CreateAdapter(); await adapter.AppendAsync( new ApiKeyAuditEntry( KeyId: null, EventType: "list-keys", RemoteAddress: null, CreatedUtc: DateTimeOffset.UtcNow, Details: null), CancellationToken.None); AuditEvent canonical = Assert.Single(await store.ListRecentAsync(10, CancellationToken.None)); Assert.Equal("cli", canonical.Actor); ApiKeyAuditEntry mappedBack = Assert.Single(await adapter.ListRecentAsync(10, CancellationToken.None)); Assert.Null(mappedBack.KeyId); Assert.Equal("list-keys", mappedBack.EventType); } /// A constraint-denied library event maps to Outcome.Denied. [Fact] public async Task Adapter_ConstraintDeniedEvent_MapsToDeniedOutcome() { (CanonicalForwardingApiKeyAuditStore adapter, SqliteCanonicalAuditStore store) = CreateAdapter(); await adapter.AppendAsync( new ApiKeyAuditEntry( KeyId: "operator01", EventType: "constraint-denied", RemoteAddress: null, CreatedUtc: DateTimeOffset.UtcNow, Details: "Write: 42: write_scope: outside scope"), CancellationToken.None); AuditEvent canonical = Assert.Single(await store.ListRecentAsync(10, CancellationToken.None)); Assert.Equal(AuditOutcome.Denied, canonical.Outcome); Assert.Equal("operator01", canonical.Actor); ApiKeyAuditEntry mappedBack = Assert.Single(await adapter.ListRecentAsync(10, CancellationToken.None)); Assert.Equal("constraint-denied", mappedBack.EventType); Assert.Equal("Write: 42: write_scope: outside scope", mappedBack.Details); } /// /// The adapter does NOT throw when the underlying write fails (it forwards through the /// best-effort writer), preserving the IApiKeyAuditStore caller's flow. /// [Fact] public async Task Adapter_WhenWriterFails_DoesNotThrow() { string badPath = Path.Combine(Path.GetTempPath(), "mxgateway-canonical-audit-fail", Guid.NewGuid().ToString("N"), "blocker"); Directory.CreateDirectory(Path.GetDirectoryName(badPath)!); File.WriteAllText(badPath, "not a directory"); AuthSqliteConnectionFactory factory = new(Path.Combine(badPath, "audit.db")); SqliteCanonicalAuditStore store = new(factory); CanonicalAuditWriter writer = new(store, NullLogger.Instance); CanonicalForwardingApiKeyAuditStore adapter = new(writer, store); await adapter.AppendAsync( new ApiKeyAuditEntry("k", "create-key", null, DateTimeOffset.UtcNow, null), CancellationToken.None); } private SqliteCanonicalAuditStore CreateStore() { TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-canonical-audit"); _tempDirectories.Add(directory); return new SqliteCanonicalAuditStore(new AuthSqliteConnectionFactory(directory.DatabasePath())); } private (CanonicalForwardingApiKeyAuditStore Adapter, SqliteCanonicalAuditStore Store) CreateAdapter() { SqliteCanonicalAuditStore store = CreateStore(); CanonicalAuditWriter writer = new(store, NullLogger.Instance); return (new CanonicalForwardingApiKeyAuditStore(writer, store), store); } private static AuditEvent MakeEvent(string action) => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, Actor = "operator01", Action = action, Outcome = AuditOutcome.Success, 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(); } }