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 SqliteApiKeyStoreTests : IAsyncLifetime { private readonly string _dbPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db"); private AuthSqliteConnectionFactory _factory = null!; private SqliteApiKeyStore _store = null!; public async Task InitializeAsync() { _factory = new AuthSqliteConnectionFactory(_dbPath); await new SqliteAuthStoreMigrator(_factory).MigrateAsync(CancellationToken.None); _store = new SqliteApiKeyStore(_factory); } [Fact] public async Task FindByKeyId_AfterInsert_ReturnsEqualRecord() { ApiKeyRecord record = SampleRecord("key-1"); await InsertAsync(record); ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-1", CancellationToken.None); Assert.NotNull(found); AssertRecordEqual(record, found!); } [Fact] public async Task FindByKeyId_ReturnsRevokedRecord() { ApiKeyRecord record = SampleRecord("key-revoked") with { RevokedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero), }; await InsertAsync(record); ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-revoked", CancellationToken.None); Assert.NotNull(found); Assert.NotNull(found!.RevokedUtc); } [Fact] public async Task FindActiveByKeyId_RevokedKey_ReturnsNull() { ApiKeyRecord record = SampleRecord("key-revoked") with { RevokedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero), }; await InsertAsync(record); ApiKeyRecord? found = await _store.FindActiveByKeyIdAsync("key-revoked", CancellationToken.None); Assert.Null(found); } [Fact] public async Task FindActiveByKeyId_ActiveKey_ReturnsRecord() { await InsertAsync(SampleRecord("key-active")); ApiKeyRecord? found = await _store.FindActiveByKeyIdAsync("key-active", CancellationToken.None); Assert.NotNull(found); } [Fact] public async Task FindByKeyId_UnknownKey_ReturnsNull() { ApiKeyRecord? found = await _store.FindByKeyIdAsync("missing", CancellationToken.None); Assert.Null(found); } [Fact] public async Task MarkUsed_ActiveKey_UpdatesLastUsed() { await InsertAsync(SampleRecord("key-active")); var when = new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero); await _store.MarkUsedAsync("key-active", when, CancellationToken.None); ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-active", CancellationToken.None); Assert.Equal(when, found!.LastUsedUtc); } [Fact] public async Task MarkUsed_RevokedKey_DoesNotUpdateLastUsed() { ApiKeyRecord record = SampleRecord("key-revoked") with { RevokedUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero), }; await InsertAsync(record); var when = new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero); await _store.MarkUsedAsync("key-revoked", when, CancellationToken.None); ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-revoked", CancellationToken.None); Assert.Null(found!.LastUsedUtc); } // --- keyId guard tests --- [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public async Task FindByKeyId_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId) { // ArgumentNullException (null) and ArgumentException (empty/whitespace) are both acceptable; // ThrowIfNullOrWhiteSpace throws ArgumentNullException for null, ArgumentException for whitespace. await Assert.ThrowsAnyAsync( () => _store.FindByKeyIdAsync(keyId!, CancellationToken.None)); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public async Task FindActiveByKeyId_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId) { await Assert.ThrowsAnyAsync( () => _store.FindActiveByKeyIdAsync(keyId!, CancellationToken.None)); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public async Task MarkUsed_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId) { await Assert.ThrowsAnyAsync( () => _store.MarkUsedAsync(keyId!, DateTimeOffset.UtcNow, CancellationToken.None)); } [Fact] public void ScopeSerializer_RoundTripsAndSortsOrdinally() { var unsorted = new HashSet(["zeta", "alpha", "mike"], StringComparer.Ordinal); var differentOrder = new HashSet(["mike", "zeta", "alpha"], StringComparer.Ordinal); string a = ScopeSerializer.Serialize(unsorted); string b = ScopeSerializer.Serialize(differentOrder); // Equal sets must produce identical column text regardless of insertion order. Assert.Equal(a, b); Assert.Equal("""["alpha","mike","zeta"]""", a); IReadOnlySet roundTripped = ScopeSerializer.Deserialize(a); Assert.True(roundTripped.SetEquals(unsorted)); } [Fact] public void ScopeSerializer_DeserializeNullOrEmpty_ReturnsEmptySet() { Assert.Empty(ScopeSerializer.Deserialize(null)); Assert.Empty(ScopeSerializer.Deserialize("")); } // --- Auth-003: corrupt scopes JSON must fail closed (empty set), never throw JsonException --- [Theory] [InlineData("not json at all")] [InlineData("{")] [InlineData("{\"a\":1}")] // valid JSON, but an object, not a string[] [InlineData("42")] // valid JSON, but a number [InlineData("[\"read\",")] // truncated/partial write public void ScopeSerializer_DeserializeMalformed_ReturnsEmptySet_DoesNotThrow(string value) { // A poisoned scopes column (tampering, partial write, format change, buggy writer) must // degrade to a zero-scope set rather than throwing on the verification hot path. IReadOnlySet scopes = ScopeSerializer.Deserialize(value); Assert.Empty(scopes); } [Fact] public async Task FindByKeyId_CorruptScopesColumn_ReturnsRecordWithEmptyScopes_DoesNotThrow() { // Insert a row whose scopes column holds malformed (non-array) JSON, then read it through // the store. The store must NOT propagate a JsonException out of FindByKeyIdAsync (which the // verifier relies on for its "only exception path is cancellation" contract). await InsertWithRawScopesAsync("key-corrupt", scopesJson: "{ this is not valid json"); ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-corrupt", CancellationToken.None); Assert.NotNull(found); Assert.Empty(found!.Scopes); } 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); private static void AssertRecordEqual(ApiKeyRecord expected, ApiKeyRecord actual) { Assert.Equal(expected.KeyId, actual.KeyId); Assert.Equal(expected.KeyPrefix, actual.KeyPrefix); Assert.Equal(expected.SecretHash, actual.SecretHash); Assert.Equal(expected.DisplayName, actual.DisplayName); Assert.True(expected.Scopes.SetEquals(actual.Scopes)); Assert.Equal(expected.ConstraintsJson, actual.ConstraintsJson); Assert.Equal(expected.CreatedUtc, actual.CreatedUtc); Assert.Equal(expected.LastUsedUtc, actual.LastUsedUtc); Assert.Equal(expected.RevokedUtc, actual.RevokedUtc); } private async Task InsertAsync(ApiKeyRecord record) { await using SqliteConnection connection = await _factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ INSERT INTO api_keys ( key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc) VALUES ( $key_id, $key_prefix, $secret_hash, $display_name, $scopes, $constraints, $created_utc, $last_used_utc, $revoked_utc); """; command.Parameters.AddWithValue("$key_id", record.KeyId); command.Parameters.AddWithValue("$key_prefix", record.KeyPrefix); command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = record.SecretHash; command.Parameters.AddWithValue("$display_name", record.DisplayName); command.Parameters.AddWithValue("$scopes", ScopeSerializer.Serialize(record.Scopes)); command.Parameters.AddWithValue("$constraints", (object?)record.ConstraintsJson ?? DBNull.Value); command.Parameters.AddWithValue("$created_utc", record.CreatedUtc.ToString("O")); command.Parameters.AddWithValue("$last_used_utc", (object?)record.LastUsedUtc?.ToString("O") ?? DBNull.Value); command.Parameters.AddWithValue("$revoked_utc", (object?)record.RevokedUtc?.ToString("O") ?? DBNull.Value); await command.ExecuteNonQueryAsync(CancellationToken.None); } private async Task InsertWithRawScopesAsync(string keyId, string scopesJson) { // Writes the scopes column verbatim (NOT via ScopeSerializer.Serialize) so a malformed // value can be persisted to simulate tampering / a partial or buggy write. await using SqliteConnection connection = await _factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ INSERT INTO api_keys ( key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc) VALUES ( $key_id, $key_prefix, $secret_hash, $display_name, $scopes, $constraints, $created_utc, $last_used_utc, $revoked_utc); """; command.Parameters.AddWithValue("$key_id", keyId); command.Parameters.AddWithValue("$key_prefix", "mxgw"); command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = new byte[] { 1, 2, 3 }; command.Parameters.AddWithValue("$display_name", "Corrupt Key"); command.Parameters.AddWithValue("$scopes", scopesJson); command.Parameters.AddWithValue("$constraints", DBNull.Value); command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UnixEpoch.ToString("O")); command.Parameters.AddWithValue("$last_used_utc", DBNull.Value); command.Parameters.AddWithValue("$revoked_utc", DBNull.Value); await command.ExecuteNonQueryAsync(CancellationToken.None); } 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. } } }