using Microsoft.Data.Sqlite; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.Auth.ApiKeys.Sqlite; namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.Live; /// /// Live tests for — the one-shot per-node SQLite to shared /// SQL Server copy. /// /// /// Each test owns a throwaway SQL Server schema AND a throwaway SQLite file under the temp /// directory; both are torn down in , so a failed run leaves nothing /// behind on a shared database or the developer's disk. /// [Collection("live-sqlserver")] public sealed class SqliteApiKeyStoreImporterLiveTests : IAsyncLifetime { private readonly string _schema = LiveSqlServer.NewSchemaName(); private readonly string _sqlitePath = Path.Combine(Path.GetTempPath(), $"zbauth-import-{Guid.NewGuid():N}.db"); private AuthSqlServerConnectionFactory _factory = null!; private SqlServerApiKeyAdminStore _sqlAdmin = null!; private SqlServerApiKeyStore _sqlRead = null!; private SqlServerApiKeyAuditStore _sqlAudit = null!; private SqliteApiKeyStoreImporter _importer = null!; /// public async Task InitializeAsync() { if (!LiveSqlServer.IsEnabled) { return; } _factory = new AuthSqlServerConnectionFactory(LiveSqlServer.OptionsFor(_schema)); await new SqlServerApiKeyStoreMigrator(_factory).MigrateAsync(CancellationToken.None); _sqlAdmin = new SqlServerApiKeyAdminStore(_factory); _sqlRead = new SqlServerApiKeyStore(_factory); _sqlAudit = new SqlServerApiKeyAuditStore(_factory); _importer = new SqliteApiKeyStoreImporter(_factory); } /// public async Task DisposeAsync() { // SQLite pools connections by connection string; the handle must be released before the // file can be removed cleanly. SqliteConnection.ClearAllPools(); foreach (string path in new[] { _sqlitePath, _sqlitePath + "-wal", _sqlitePath + "-shm" }) { if (File.Exists(path)) { File.Delete(path); } } await LiveSqlServer.DropSchemaAsync(_schema); } [SkippableFact] public async Task Import_CopiesKeysAndAuditRows_AndReturnsCounts() { Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason); (ApiKeyRecord keyA, ApiKeyRecord keyB) = await SeedSqliteAsync(); ApiKeyImportResult result = await _importer.ImportAsync(_sqlitePath, CancellationToken.None); Assert.True(result.SourceFound); Assert.Equal(2, result.KeysInserted); Assert.Equal(0, result.KeysSkipped); Assert.Equal(3, result.AuditInserted); Assert.Equal(0, result.AuditSkipped); await AssertRecordMatchesAsync(keyA); await AssertRecordMatchesAsync(keyB); IReadOnlyList audit = await _sqlAudit.ListRecentAsync(10, CancellationToken.None); Assert.Equal(3, audit.Count); Assert.Contains(audit, e => e.KeyId is null && e.EventType == "auth_failed"); Assert.Contains(audit, e => e.KeyId == "key-a" && e.EventType == "created"); Assert.Contains(audit, e => e.KeyId == "key-b" && e.EventType == "created"); } [SkippableFact] public async Task Import_Twice_IsIdempotent() { Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason); await SeedSqliteAsync(); await _importer.ImportAsync(_sqlitePath, CancellationToken.None); ApiKeyImportResult second = await _importer.ImportAsync(_sqlitePath, CancellationToken.None); Assert.True(second.SourceFound); Assert.Equal(0, second.KeysInserted); Assert.Equal(2, second.KeysSkipped); Assert.Equal(0, second.AuditInserted); Assert.Equal(3, second.AuditSkipped); Assert.Equal(2, (await _sqlAdmin.ListAsync(CancellationToken.None)).Count); Assert.Equal(3, (await _sqlAudit.ListRecentAsync(10, CancellationToken.None)).Count); } [SkippableFact] public async Task Import_UnionsWithExistingRows() { Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason); var lastUsed = new DateTimeOffset(2026, 7, 4, 12, 0, 0, TimeSpan.Zero); ApiKeyRecord keyC = SampleRecord("key-c") with { LastUsedUtc = lastUsed }; await _sqlAdmin.CreateAsync(keyC, CancellationToken.None); await SeedSqliteAsync(); ApiKeyImportResult result = await _importer.ImportAsync(_sqlitePath, CancellationToken.None); Assert.Equal(2, result.KeysInserted); Assert.Equal(0, result.KeysSkipped); IReadOnlyList listed = await _sqlAdmin.ListAsync(CancellationToken.None); Assert.Equal( ["key-a", "key-b", "key-c"], listed.Select(k => k.KeyId).OrderBy(k => k, StringComparer.Ordinal)); // The pre-existing row is untouched — the importer only ever inserts. Assert.Equal(lastUsed, Assert.Single(listed, k => k.KeyId == "key-c").LastUsedUtc); } [SkippableFact] public async Task Import_MissingFile_ReturnsZeroWithoutThrowing() { Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason); ApiKeyImportResult result = await _importer.ImportAsync( Path.Combine(Path.GetTempPath(), $"zbauth-absent-{Guid.NewGuid():N}.db"), CancellationToken.None); Assert.False(result.SourceFound); Assert.Equal(0, result.KeysInserted); Assert.Equal(0, result.KeysSkipped); Assert.Equal(0, result.AuditInserted); Assert.Equal(0, result.AuditSkipped); } [SkippableFact] public async Task Import_PreV3FileWithoutExpiresColumn_ReadsExpiryAsNull() { Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason); await CreatePreV3SqliteFileAsync(); ApiKeyImportResult result = await _importer.ImportAsync(_sqlitePath, CancellationToken.None); Assert.True(result.SourceFound); Assert.Equal(1, result.KeysInserted); ApiKeyRecord? found = await _sqlRead.FindByKeyIdAsync("key-old", CancellationToken.None); Assert.NotNull(found); Assert.Null(found!.ExpiresUtc); Assert.Equal("legacy key", found.DisplayName); Assert.Equal(new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero), found.CreatedUtc); } // --- helpers --- /// /// Seeds the throwaway SQLite file with two keys (one revoked, one with an expiry) and three /// audit rows (one of them key-less), then returns the two key records as SQLite holds them. /// private async Task<(ApiKeyRecord KeyA, ApiKeyRecord KeyB)> SeedSqliteAsync() { var sqliteFactory = new AuthSqliteConnectionFactory(_sqlitePath); await new SqliteAuthStoreMigrator(sqliteFactory).MigrateAsync(CancellationToken.None); var admin = new SqliteApiKeyAdminStore(sqliteFactory); var audit = new SqliteApiKeyAuditStore(sqliteFactory); var read = new SqliteApiKeyStore(sqliteFactory); ApiKeyRecord keyA = SampleRecord("key-a"); ApiKeyRecord keyB = SampleRecord("key-b") with { ExpiresUtc = new DateTimeOffset(2027, 3, 1, 0, 0, 0, TimeSpan.Zero), }; await admin.CreateAsync(keyA, CancellationToken.None); await admin.CreateAsync(keyB, CancellationToken.None); await admin.RevokeAsync("key-a", new DateTimeOffset(2026, 6, 1, 7, 8, 9, TimeSpan.Zero), CancellationToken.None); await audit.AppendAsync( new ApiKeyAuditEntry("key-a", "created", "10.0.0.1", new DateTimeOffset(2026, 5, 1, 8, 30, 0, TimeSpan.Zero), "by admin"), CancellationToken.None); await audit.AppendAsync( new ApiKeyAuditEntry("key-b", "created", null, new DateTimeOffset(2026, 5, 1, 8, 31, 0, TimeSpan.Zero), null), CancellationToken.None); await audit.AppendAsync( new ApiKeyAuditEntry(null, "auth_failed", "10.0.0.9", new DateTimeOffset(2026, 5, 2, 9, 0, 0, TimeSpan.Zero), "unknown key"), CancellationToken.None); // Re-read so the assertions compare against exactly what SQLite holds, revocation included. ApiKeyRecord storedA = (await read.FindByKeyIdAsync("key-a", CancellationToken.None))!; ApiKeyRecord storedB = (await read.FindByKeyIdAsync("key-b", CancellationToken.None))!; return (storedA, storedB); } /// /// Writes a v2-shaped SQLite file by hand — api_keys WITHOUT expires_utc — and /// inserts one key row, reproducing a store created before schema v3. /// private async Task CreatePreV3SqliteFileAsync() { await using var connection = new SqliteConnection( new SqliteConnectionStringBuilder { DataSource = _sqlitePath }.ToString()); await connection.OpenAsync(); await using SqliteCommand ddl = connection.CreateCommand(); ddl.CommandText = """ CREATE TABLE api_keys ( key_id TEXT PRIMARY KEY, key_prefix TEXT NOT NULL, secret_hash BLOB NOT NULL, display_name TEXT NOT NULL, scopes TEXT NOT NULL, constraints TEXT NULL, created_utc TEXT NOT NULL, last_used_utc TEXT NULL, revoked_utc TEXT NULL ); CREATE TABLE api_key_audit ( audit_id INTEGER PRIMARY KEY AUTOINCREMENT, key_id TEXT NULL, event_type TEXT NOT NULL, remote_address TEXT NULL, created_utc TEXT NOT NULL, details TEXT NULL ); """; await ddl.ExecuteNonQueryAsync(); await using SqliteCommand insert = connection.CreateCommand(); insert.CommandText = """ INSERT INTO api_keys ( key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc) VALUES ('key-old', 'sbk_old1', $hash, 'legacy key', 'read', NULL, $created, NULL, NULL); """; insert.Parameters.Add("$hash", SqliteType.Blob).Value = new byte[] { 4, 5, 6 }; insert.Parameters.AddWithValue( "$created", new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero).ToString("O")); await insert.ExecuteNonQueryAsync(); } private async Task AssertRecordMatchesAsync(ApiKeyRecord expected) { ApiKeyRecord? actual = await _sqlRead.FindByKeyIdAsync(expected.KeyId, CancellationToken.None); Assert.NotNull(actual); Assert.Equal(expected.KeyPrefix, actual!.KeyPrefix); Assert.True(expected.SecretHash.SequenceEqual(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); Assert.Equal(expected.ExpiresUtc, actual.ExpiresUtc); } private static ApiKeyRecord SampleRecord(string keyId) => new( KeyId: keyId, KeyPrefix: "sbk_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); }