using Microsoft.Data.Sqlite; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Sqlite; namespace ZB.MOM.WW.Secrets.Tests.Sqlite; public sealed class SqliteSecretsStoreMigratorTests : IDisposable { private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"zb-secrets-migrator-{Guid.NewGuid():N}.db"); [Fact] public async Task Migrate_IsIdempotent() { var factory = new SecretsSqliteConnectionFactory(_dbPath); var migrator = new SqliteSecretsStoreMigrator(factory); // Running twice must not throw and must leave the schema version at CurrentVersion. await migrator.MigrateAsync(CancellationToken.None); await migrator.MigrateAsync(CancellationToken.None); long version = await ReadSchemaVersionAsync(factory); Assert.Equal(SqliteSecretsSchema.CurrentVersion, version); Assert.Equal(1, version); } [Fact] public async Task Migrate_CreatesSecretTableWithExpectedColumns() { var factory = new SecretsSqliteConnectionFactory(_dbPath); var migrator = new SqliteSecretsStoreMigrator(factory); await migrator.MigrateAsync(CancellationToken.None); HashSet columns = await ReadSecretColumnsAsync(factory); string[] expected = [ "name", "description", "content_type", "ciphertext", "nonce", "tag", "wrapped_dek", "wrap_nonce", "wrap_tag", "kek_id", "revision", "is_deleted", "deleted_utc", "created_utc", "updated_utc", "created_by", "updated_by", ]; Assert.Equal(17, expected.Length); Assert.Equal(17, columns.Count); foreach (string column in expected) { Assert.Contains(column, columns); } } [Fact] public async Task Migrate_RefusesNewerSchema() { var factory = new SecretsSqliteConnectionFactory(_dbPath); // Manually stamp an on-disk version newer than this build supports. await using (SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None)) { await using SqliteCommand create = connection.CreateCommand(); create.CommandText = SqliteSecretsSchema.CreateSchemaVersionTable; await create.ExecuteNonQueryAsync(); await using SqliteCommand insert = connection.CreateCommand(); insert.CommandText = """ INSERT INTO schema_version (id, version, applied_utc) VALUES (1, $version, $applied_utc); """; insert.Parameters.AddWithValue("$version", 999); insert.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O")); await insert.ExecuteNonQueryAsync(); } var migrator = new SqliteSecretsStoreMigrator(factory); await Assert.ThrowsAsync( () => migrator.MigrateAsync(CancellationToken.None)); } private static async Task ReadSchemaVersionAsync(SecretsSqliteConnectionFactory factory) { 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? result = await command.ExecuteScalarAsync(); return Convert.ToInt64(result); } private static async Task> ReadSecretColumnsAsync(SecretsSqliteConnectionFactory factory) { await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = $"PRAGMA table_info({SqliteSecretsSchema.SecretTable});"; var columns = new HashSet(StringComparer.Ordinal); await using SqliteDataReader reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { // PRAGMA table_info columns: cid(0), name(1), type(2), ... columns.Add(reader.GetString(1)); } return columns; } public void Dispose() { // Drop pooled connections so the WAL/-shm/-wal sidecars release before we delete. SqliteConnection.ClearAllPools(); foreach (string path in new[] { _dbPath, _dbPath + "-wal", _dbPath + "-shm" }) { try { if (File.Exists(path)) { File.Delete(path); } } catch (IOException) { // Best-effort temp cleanup; a leaked temp file is not a test failure. } } } }