using Microsoft.Data.Sqlite; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.Auth.ApiKeys.Sqlite; namespace ZB.MOM.WW.Auth.ApiKeys.Tests; public sealed class SqliteApiKeyAdminStoreTests : IAsyncLifetime { private readonly string _dbPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db"); private AuthSqliteConnectionFactory _factory = null!; private SqliteApiKeyAdminStore _admin = null!; private SqliteApiKeyStore _read = null!; private SqliteApiKeyAuditStore _audit = null!; public async Task InitializeAsync() { _factory = new AuthSqliteConnectionFactory(_dbPath); await new SqliteAuthStoreMigrator(_factory).MigrateAsync(CancellationToken.None); _admin = new SqliteApiKeyAdminStore(_factory); _read = new SqliteApiKeyStore(_factory); _audit = new SqliteApiKeyAuditStore(_factory); } // --- Create --- [Fact] public async Task Create_ThenFindByKeyId_ReturnsRecord() { ApiKeyRecord record = SampleRecord("key-1"); await _admin.CreateAsync(record, CancellationToken.None); ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None); Assert.NotNull(found); Assert.Equal(record.SecretHash, found!.SecretHash); Assert.True(record.Scopes.SetEquals(found.Scopes)); Assert.Equal(record.ConstraintsJson, found.ConstraintsJson); Assert.Null(found.LastUsedUtc); Assert.Null(found.RevokedUtc); } // --- Revoke --- [Fact] public async Task Revoke_ActiveKey_SetsRevokedAndFindActiveReturnsNull() { await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None); var when = new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero); bool result = await _admin.RevokeAsync("key-1", when, CancellationToken.None); Assert.True(result); Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None)); ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None); Assert.Equal(when, found!.RevokedUtc); } [Fact] public async Task Revoke_UnknownKey_ReturnsFalse() { bool result = await _admin.RevokeAsync("missing", DateTimeOffset.UtcNow, CancellationToken.None); Assert.False(result); } [Fact] public async Task Revoke_AlreadyRevoked_ReturnsFalse() { await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None); await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None); bool result = await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None); Assert.False(result); } // --- Rotate --- [Fact] public async Task Rotate_ChangesHashAndReactivates() { await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None); await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None); await _read.MarkUsedAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None); byte[] newHash = [9, 9, 9, 9]; bool result = await _admin.RotateAsync("key-1", newHash, CancellationToken.None); Assert.True(result); ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None); Assert.Equal(newHash, found!.SecretHash); Assert.Null(found.RevokedUtc); Assert.Null(found.LastUsedUtc); // Reactivated: now visible as active. Assert.NotNull(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None)); } [Fact] public async Task Rotate_UnknownKey_ReturnsFalse() { bool result = await _admin.RotateAsync("missing", [1], CancellationToken.None); Assert.False(result); } // --- Delete --- [Fact] public async Task Delete_ActiveKey_ReturnsFalseAndKeyStillPresent() { await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None); bool result = await _admin.DeleteAsync("key-1", CancellationToken.None); Assert.False(result); Assert.NotNull(await _read.FindByKeyIdAsync("key-1", CancellationToken.None)); } [Fact] public async Task Delete_RevokedKey_ReturnsTrueAndKeyGone() { await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None); await _admin.RevokeAsync("key-1", DateTimeOffset.UtcNow, CancellationToken.None); bool result = await _admin.DeleteAsync("key-1", CancellationToken.None); Assert.True(result); Assert.Null(await _read.FindByKeyIdAsync("key-1", CancellationToken.None)); } [Fact] public async Task Delete_UnknownKey_ReturnsFalse() { bool result = await _admin.DeleteAsync("missing", CancellationToken.None); Assert.False(result); } // --- keyId guard tests --- [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public async Task Revoke_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId) { // ArgumentNullException (null) and ArgumentException (empty/whitespace) are both acceptable; // ThrowIfNullOrWhiteSpace throws ArgumentNullException for null, ArgumentException for whitespace. await Assert.ThrowsAnyAsync( () => _admin.RevokeAsync(keyId!, DateTimeOffset.UtcNow, CancellationToken.None)); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public async Task Rotate_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId) { await Assert.ThrowsAnyAsync( () => _admin.RotateAsync(keyId!, [1, 2, 3], CancellationToken.None)); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public async Task Delete_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId) { await Assert.ThrowsAnyAsync( () => _admin.DeleteAsync(keyId!, CancellationToken.None)); } // --- Audit --- [Fact] public async Task Audit_AppendThenListRecent_ReturnsEntry() { var entry = new ApiKeyAuditEntry( KeyId: "key-1", EventType: "created", RemoteAddress: "10.0.0.1", CreatedUtc: new DateTimeOffset(2026, 5, 31, 10, 0, 0, TimeSpan.Zero), Details: "by admin"); await _audit.AppendAsync(entry, CancellationToken.None); IReadOnlyList recent = await _audit.ListRecentAsync(10, CancellationToken.None); Assert.Single(recent); Assert.Equal("key-1", recent[0].KeyId); Assert.Equal("created", recent[0].EventType); Assert.Equal("10.0.0.1", recent[0].RemoteAddress); Assert.Equal("by admin", recent[0].Details); } [Fact] public async Task Audit_ListRecent_ReturnsNewestFirst() { await _audit.AppendAsync( new ApiKeyAuditEntry("k", "first", null, DateTimeOffset.UtcNow, null), CancellationToken.None); await _audit.AppendAsync( new ApiKeyAuditEntry("k", "second", null, DateTimeOffset.UtcNow, null), CancellationToken.None); await _audit.AppendAsync( new ApiKeyAuditEntry("k", "third", null, DateTimeOffset.UtcNow, null), CancellationToken.None); IReadOnlyList recent = await _audit.ListRecentAsync(10, CancellationToken.None); Assert.Equal(["third", "second", "first"], recent.Select(e => e.EventType)); } [Fact] public async Task Audit_ListRecent_RespectsLimit() { for (int i = 0; i < 5; i++) { await _audit.AppendAsync( new ApiKeyAuditEntry("k", $"e{i}", null, DateTimeOffset.UtcNow, null), CancellationToken.None); } IReadOnlyList recent = await _audit.ListRecentAsync(2, CancellationToken.None); Assert.Equal(2, recent.Count); Assert.Equal(["e4", "e3"], recent.Select(e => e.EventType)); } [Fact] public async Task Audit_NullableFields_RoundTripAsNull() { await _audit.AppendAsync( new ApiKeyAuditEntry(null, "anon", null, DateTimeOffset.UtcNow, null), CancellationToken.None); IReadOnlyList recent = await _audit.ListRecentAsync(10, CancellationToken.None); Assert.Single(recent); Assert.Null(recent[0].KeyId); Assert.Null(recent[0].RemoteAddress); Assert.Null(recent[0].Details); } private static ApiKeyRecord SampleRecord(string keyId) => new( KeyId: keyId, KeyPrefix: "mxgw_ab12", SecretHash: [1, 2, 3, 4, 5, 6, 7, 8], DisplayName: "Test Key " + keyId, Scopes: new HashSet(["read", "write"], StringComparer.Ordinal), ConstraintsJson: """{"ipAllow":["10.0.0.0/8"]}""", CreatedUtc: new DateTimeOffset(2026, 5, 1, 8, 30, 0, TimeSpan.Zero), LastUsedUtc: null, RevokedUtc: null); public Task DisposeAsync() { SqliteConnection.ClearAllPools(); TryDelete(_dbPath); TryDelete(_dbPath + "-wal"); TryDelete(_dbPath + "-shm"); return Task.CompletedTask; } private static void TryDelete(string path) { try { if (File.Exists(path)) { File.Delete(path); } } catch (IOException) { // Best-effort cleanup of the per-test temp database. } } }