using Microsoft.Data.Sqlite; using ZB.MOM.WW.Auth.ApiKeys.Sqlite; namespace ZB.MOM.WW.Auth.ApiKeys.Tests; public sealed class SqliteMigratorTests : IDisposable { private readonly string _dbPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db"); private AuthSqliteConnectionFactory Factory => new(_dbPath); [Fact] public async Task MigrateAsync_CreatesAllThreeTables() { var migrator = new SqliteAuthStoreMigrator(Factory); await migrator.MigrateAsync(CancellationToken.None); Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable)); Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeyAuditTable)); Assert.True(await TableExistsAsync(SqliteAuthSchema.SchemaVersionTable)); } [Fact] public async Task MigrateAsync_RunTwice_IsIdempotentAndRecordsCurrentVersion() { var migrator = new SqliteAuthStoreMigrator(Factory); await migrator.MigrateAsync(CancellationToken.None); await migrator.MigrateAsync(CancellationToken.None); Assert.Equal(SqliteAuthSchema.CurrentVersion, await ReadVersionAsync()); Assert.Equal(1, await CountSchemaVersionRowsAsync()); } [Fact] public void CurrentVersion_Is3_ForExpiresUtcColumn() => // v3 adds the nullable expires_utc column (archreview G-2). The value must stay >= 2 so the // migrator does not refuse a donor MxAccessGateway gateway-auth.db (stamped 2) on first boot: // 2 <= 3, so such a DB is accepted and upgraded in place. Locking this invariant. Assert.Equal(3, SqliteAuthSchema.CurrentVersion); [Fact] public async Task MigrateAsync_AgainstExistingVersion2Db_DoesNotThrow_AndUpgradesTo3() { // The deployed-gateway scenario: a database already provisioned at the donor's version 2. // Migrating must accept it (2 <= 3), add the expires_utc column, and re-stamp to 3 — no key // is invalidated, the table survives. var migrator = new SqliteAuthStoreMigrator(Factory); await migrator.MigrateAsync(CancellationToken.None); await SetVersionAsync(2); await migrator.MigrateAsync(CancellationToken.None); // must not throw Assert.Equal(3, await ReadVersionAsync()); Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable)); Assert.True(await ColumnExistsAsync(SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn)); } [Fact] public async Task MigrateAsync_PreV3ApiKeysTableWithoutExpiresUtc_AddsColumn_AndPreservesRows() { // Simulate a genuine pre-expiry (v2) database: an api_keys table WITHOUT expires_utc, holding // an existing key. The forward migration must add the column (existing row → NULL) without // dropping the row. await CreateLegacyV2SchemaWithKeyAsync("legacy-key"); Assert.False(await ColumnExistsAsync(SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn)); await new SqliteAuthStoreMigrator(Factory).MigrateAsync(CancellationToken.None); Assert.True(await ColumnExistsAsync(SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn)); Assert.Equal(3, await ReadVersionAsync()); // The pre-existing key survives and its new expires_utc is NULL (never-expires). var read = new SqliteApiKeyStore(Factory); var found = await read.FindByKeyIdAsync("legacy-key", CancellationToken.None); Assert.NotNull(found); Assert.Null(found!.ExpiresUtc); } private async Task CreateLegacyV2SchemaWithKeyAsync(string keyId) { await using SqliteConnection connection = await Factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.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 ); {SqliteAuthSchema.CreateSchemaVersionTable} INSERT INTO schema_version (id, version, applied_utc) VALUES (1, 2, '2026-01-01T00:00:00.0000000+00:00'); INSERT INTO api_keys (key_id, key_prefix, secret_hash, display_name, scopes, created_utc) VALUES ($key_id, 'mxgw', X'01020304', 'Legacy', 'read', '2026-01-01T00:00:00.0000000+00:00'); """; command.Parameters.AddWithValue("$key_id", keyId); await command.ExecuteNonQueryAsync(CancellationToken.None); } private async Task ColumnExistsAsync(string table, string column) { await using SqliteConnection connection = await Factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = $"PRAGMA table_info({table});"; await using SqliteDataReader reader = await command.ExecuteReaderAsync(CancellationToken.None); while (await reader.ReadAsync(CancellationToken.None)) { if (string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } [Fact] public async Task MigrateAsync_FutureSchemaVersion_Throws() { var migrator = new SqliteAuthStoreMigrator(Factory); await migrator.MigrateAsync(CancellationToken.None); await SetVersionAsync(99); await Assert.ThrowsAsync( () => migrator.MigrateAsync(CancellationToken.None)); } private async Task TableExistsAsync(string tableName) { await using SqliteConnection connection = await Factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $name;"; command.Parameters.AddWithValue("$name", tableName); long count = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L); return count == 1; } private async Task ReadVersionAsync() { await using SqliteConnection connection = await Factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = "SELECT version FROM schema_version WHERE id = 1;"; object? value = await command.ExecuteScalarAsync(CancellationToken.None); return Convert.ToInt32(value, System.Globalization.CultureInfo.InvariantCulture); } private async Task CountSchemaVersionRowsAsync() { await using SqliteConnection connection = await Factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = "SELECT COUNT(*) FROM schema_version;"; long count = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L); return (int)count; } private async Task SetVersionAsync(int version) { await using SqliteConnection connection = await Factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = "UPDATE schema_version SET version = $version WHERE id = 1;"; command.Parameters.AddWithValue("$version", version); await command.ExecuteNonQueryAsync(CancellationToken.None); } public void Dispose() { SqliteConnection.ClearAllPools(); TryDelete(_dbPath); TryDelete(_dbPath + "-wal"); TryDelete(_dbPath + "-shm"); } 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. } } }