feat(audit): MxGateway canonical SQLite audit_event store + IAuditWriter + IApiKeyAuditStore->canonical adapter (Task 2.3)
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests the Task 2.3 canonical audit plumbing: the gateway-owned
|
||||
/// <see cref="SqliteCanonicalAuditStore"/> (round-trips canonical
|
||||
/// <see cref="AuditEvent"/>s through the new <c>audit_event</c> table), the best-effort
|
||||
/// <see cref="CanonicalAuditWriter"/>, and the <see cref="CanonicalForwardingApiKeyAuditStore"/>
|
||||
/// adapter that maps the library's <see cref="ApiKeyAuditEntry"/> 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.
|
||||
/// </summary>
|
||||
public sealed class CanonicalAuditStoreAndAdapterTests : IDisposable
|
||||
{
|
||||
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
|
||||
|
||||
/// <summary>A canonical event with all fields populated round-trips through the store.</summary>
|
||||
[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<AuditEvent> 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);
|
||||
}
|
||||
|
||||
/// <summary>Nullable canonical fields round-trip as null, not empty string.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>ListRecent returns newest-first.</summary>
|
||||
[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<AuditEvent> recent = await store.ListRecentAsync(10, CancellationToken.None);
|
||||
Assert.Equal(["third", "second", "first"], recent.Select(e => e.Action));
|
||||
}
|
||||
|
||||
/// <summary>The writer is best-effort: a faulting store does not surface to the caller.</summary>
|
||||
[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<CanonicalAuditWriter>.Instance);
|
||||
|
||||
// Must not throw.
|
||||
await writer.WriteAsync(MakeEvent("anything"), CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>The keyless library <c>init-db</c> event maps to Actor "system" and back to a null KeyId.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Any other keyless library event maps to Actor "cli" and back to a null KeyId.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>A <c>constraint-denied</c> library event maps to Outcome.Denied.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The adapter does NOT throw when the underlying write fails (it forwards through the
|
||||
/// best-effort writer), preserving the IApiKeyAuditStore caller's flow.
|
||||
/// </summary>
|
||||
[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<CanonicalAuditWriter>.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<CanonicalAuditWriter>.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",
|
||||
};
|
||||
|
||||
/// <summary>Clears SQLite pools and deletes every temporary directory created by this test.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (TempDatabaseDirectory directory in _tempDirectories)
|
||||
{
|
||||
directory.Dispose();
|
||||
}
|
||||
|
||||
_tempDirectories.Clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user