feat(secrets): sqlite connection factory + schema v1 + migrator

This commit is contained in:
Joseph Doherty
2026-07-15 16:46:15 -04:00
parent 1c13b4af99
commit e99a8731e9
4 changed files with 410 additions and 0 deletions
@@ -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.
}
}
}
}