using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.DependencyInjection; using ZB.MOM.WW.Secrets.Sqlite; namespace ZB.MOM.WW.Secrets.Tests.Sqlite; /// /// scadaproj#25: several hosts in one process initialising the SAME SQLite secret store at once /// (mxaccessgw's parallel test classes, each building a host whose migration hosted service and /// store point at the per-process default path). On Windows this surfaced as /// "cannot start a transaction within a transaction". Each simulated host owns its own /// connection factory, migrator and store, exactly as separate DI containers would. /// public sealed class SqliteSecretsStoreConcurrentInitTests : IDisposable { private const int Hosts = 16; private const int Rounds = 20; private readonly string _root = Path.Combine(Path.GetTempPath(), $"zb-secrets-concurrent-{Guid.NewGuid():N}"); [Fact] public async Task ConcurrentInitialisation_OfOneFreshStore_AllHostsSucceed() { for (int round = 0; round < Rounds; round++) { string path = Path.Combine(_root, $"round-{round}", "secrets.db"); await RunHostsAsync(path, round); await AssertStoreAsync(path, expectedSecrets: Hosts); } } [Fact] public async Task ConcurrentInitialisation_OfOneExistingStore_AllHostsSucceed() { string path = Path.Combine(_root, "existing", "secrets.db"); await new SqliteSecretsStoreMigrator(new SecretsSqliteConnectionFactory(path)) .MigrateAsync(CancellationToken.None); for (int round = 0; round < Rounds; round++) { await RunHostsAsync(path, round); } await AssertStoreAsync(path, expectedSecrets: Hosts * Rounds); } [Fact] public async Task ConcurrentInitialisation_ThroughDifferentSpellingsOfOnePath_AllHostsSucceed() { // The per-path gate keys on the NORMALISED path: a relative spelling and a "..\" detour // must land on the same gate as the canonical absolute path. string dir = Path.Combine(_root, "spellings"); string canonical = Path.Combine(dir, "secrets.db"); string detour = Path.Combine(dir, "sub", "..", "secrets.db"); string[] spellings = [canonical, detour]; Task[] hosts = new Task[Hosts]; TaskCompletionSource start = new(TaskCreationOptions.RunContinuationsAsynchronously); for (int i = 0; i < Hosts; i++) { string spelling = spellings[i % spellings.Length]; int host = i; hosts[i] = Task.Run(async () => { await start.Task; await RunOneHostAsync(spelling, $"spell/{host}"); }); } start.SetResult(); await Task.WhenAll(hosts); await AssertStoreAsync(canonical, expectedSecrets: Hosts); } [Fact] public async Task ConcurrentInitialisation_ThroughSeparateAddZbSecretsContainers_AllSucceed() { // The exact mxaccessgw shape: every host builds its own container with AddZbSecrets, runs the // pre-host migrator, then starts the SecretsMigrationHostedService — all on one store path. string path = Path.Combine(_root, "di", "secrets.db"); IConfiguration configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Secrets:SqlitePath"] = path, ["Secrets:RunMigrationsOnStartup"] = "true", }) .Build(); TaskCompletionSource start = new(TaskCreationOptions.RunContinuationsAsynchronously); Task[] hosts = new Task[Hosts]; for (int i = 0; i < Hosts; i++) { hosts[i] = Task.Run(async () => { await using ServiceProvider provider = new ServiceCollection() .AddZbSecrets(configuration, "Secrets") .BuildServiceProvider(); await start.Task; provider.GetRequiredService() .MigrateAsync(default).GetAwaiter().GetResult(); foreach (IHostedService hosted in provider.GetServices()) { if (hosted.GetType().Name == "SecretsMigrationHostedService") { await hosted.StartAsync(CancellationToken.None); } } _ = await provider.GetRequiredService().ListAsync(includeDeleted: true, CancellationToken.None); }); } start.SetResult(); await Task.WhenAll(hosts); await AssertStoreAsync(path, expectedSecrets: 0); } private static async Task RunHostsAsync(string path, int round) { TaskCompletionSource start = new(TaskCreationOptions.RunContinuationsAsynchronously); Task[] hosts = new Task[Hosts]; for (int i = 0; i < Hosts; i++) { int host = i; hosts[i] = Task.Run(async () => { await start.Task; await RunOneHostAsync(path, $"r{round}/h{host}"); }); } // Release every host at once so their migrations genuinely overlap. start.SetResult(); await Task.WhenAll(hosts); } private static async Task RunOneHostAsync(string path, string secretName) { // One "host": its own factory, migrator and store, as a separate DI container would build. SecretsSqliteConnectionFactory factory = new(path); await new SqliteSecretsStoreMigrator(factory).MigrateAsync(CancellationToken.None); SqliteSecretStore store = new(factory); StoredSecret row = MakeSecret(secretName); await store.UpsertAsync(row, CancellationToken.None); await store.ApplyReplicatedAsync(row with { Revision = 5, UpdatedUtc = row.UpdatedUtc.AddSeconds(1) }, CancellationToken.None); Assert.NotNull(await store.GetAsync(row.Name, CancellationToken.None)); _ = await store.ListAsync(includeDeleted: false, CancellationToken.None); } private static async Task AssertStoreAsync(string path, int expectedSecrets) { SecretsSqliteConnectionFactory factory = new(path); await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None); await using SqliteCommand version = connection.CreateCommand(); version.CommandText = "SELECT version FROM schema_version WHERE id = 1;"; Assert.Equal((long)SqliteSecretsSchema.CurrentVersion, Convert.ToInt64(await version.ExecuteScalarAsync())); await using SqliteCommand count = connection.CreateCommand(); count.CommandText = "SELECT COUNT(*) FROM secret;"; Assert.Equal((long)expectedSecrets, Convert.ToInt64(await count.ExecuteScalarAsync())); // No pooled connection may be handed back with a transaction still open: a fresh // BEGIN on a pooled connection is exactly what failed on Windows. await using SqliteTransaction tx = (SqliteTransaction) await connection.BeginTransactionAsync(System.Data.IsolationLevel.Serializable); await tx.CommitAsync(); } private static StoredSecret MakeSecret(string name) => new() { Name = new SecretName(name), Description = "concurrent-init", ContentType = SecretContentType.ConnectionString, Ciphertext = [1, 2, 3], Nonce = [4, 5, 6], Tag = [7, 8, 9], WrappedDek = [10, 11, 12], WrapNonce = [13, 14, 15], WrapTag = [16, 17, 18], KekId = "kek-1", Revision = 0, IsDeleted = false, DeletedUtc = null, CreatedUtc = DateTimeOffset.UtcNow, UpdatedUtc = DateTimeOffset.UtcNow, CreatedBy = "concurrent", UpdatedBy = "concurrent", }; public void Dispose() { SqliteConnection.ClearAllPools(); try { if (Directory.Exists(_root)) { Directory.Delete(_root, recursive: true); } } catch (IOException) { // Best-effort temp cleanup. } catch (UnauthorizedAccessException) { // Best-effort temp cleanup. } } }