feat(secrets): sqlite connection factory + schema v1 + migrator
This commit is contained in:
+127
@@ -0,0 +1,127 @@
|
||||
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<string> 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<SecretStoreMigrationException>(
|
||||
() => migrator.MigrateAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
private static async Task<long> 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<HashSet<string>> 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<string>(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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user