From 06be0b9e6480fce32a499e193a28007d860859af Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 2 Sep 2026 23:26:03 -0400 Subject: [PATCH] =?UTF-8?q?feat(host):=20inbound=20API=20key=20store=20pro?= =?UTF-8?q?vider=20=E2=80=94=20SqlServer=20shares=20one=20zbauth=20row=20s?= =?UTF-8?q?et=20across=20the=20central=20pair;=20one-shot=20SQLite=20impor?= =?UTF-8?q?t=20at=20boot=20(file=20renamed=20.imported,=20one=20retry=20on?= =?UTF-8?q?=20a=20concurrent-peer=20PK=20race,=20failure=20never=20blocks?= =?UTF-8?q?=20boot)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB --- .../InboundApiKeyStoreImportService.cs | 165 +++++++++++ src/ZB.MOM.WW.ScadaBridge.Host/Program.cs | 42 ++- .../CompositionRootTests.cs | 228 +++++++++++++++ .../InboundApiKeyStoreImportServiceTests.cs | 262 ++++++++++++++++++ 4 files changed, 695 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Host/InboundApiKeyStoreImportService.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreImportServiceTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/InboundApiKeyStoreImportService.cs b/src/ZB.MOM.WW.ScadaBridge.Host/InboundApiKeyStoreImportService.cs new file mode 100644 index 00000000..2deb0cbd --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Host/InboundApiKeyStoreImportService.cs @@ -0,0 +1,165 @@ +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.Auth.Abstractions.ApiKeys; +using ZB.MOM.WW.Auth.ApiKeys.SqlServer; + +namespace ZB.MOM.WW.ScadaBridge.Host; + +/// +/// Seam over — the library type is sealed and has no +/// interface, so this is what lets be unit-tested +/// without a SQL Server. +/// +internal interface IInboundApiKeyStoreImporter +{ + /// Copies every key and audit row from the SQLite file at into the shared store. + /// Path of the per-node SQLite API-key file. + /// A token to cancel the import. + /// Per-category insert/skip counts. + Task ImportAsync(string sqlitePath, CancellationToken ct); +} + +/// The production seam implementation: a straight delegation to the library importer. +/// The library importer, resolved from the SQL Server API-key registration. +internal sealed class LibraryInboundApiKeyStoreImporter(SqliteApiKeyStoreImporter inner) + : IInboundApiKeyStoreImporter +{ + /// + public Task ImportAsync(string sqlitePath, CancellationToken ct) + => inner.ImportAsync(sqlitePath, ct); +} + +/// +/// One-shot migration of a central node's legacy PER-NODE inbound API-key SQLite file into the +/// shared SQL Server store, run at boot when +/// ScadaBridge:InboundApi:ApiKeyStore:Provider = SqlServer (design 2026-09-02, deferred-work +/// register row 108). Before the shared store existed, a key minted through the management API was +/// written only to whichever central node served that call, so a failover left every inbound client +/// 401ing; importing both nodes' files into the one shared store is what closes that gap for keys +/// that already exist. +/// +/// +/// +/// The source is when set, +/// otherwise the library's own — which still points +/// at the node's pre-existing file even under the SQL Server provider, since +/// AddZbApiKeyAuthSqlServer keeps that option bound. A missing file is the normal +/// steady state and costs nothing. +/// +/// +/// The import itself is idempotent (insert-where-absent per key and per audit row), so both +/// nodes of a pair may run it and the shared store ends up holding the union of their files. +/// Both booting at once can still race under read-committed and produce a primary-key +/// violation (SQL Server error 2627) — that ONE error is retried once, because on the +/// second pass the peer's rows are simply seen and skipped. Any other failure is not retried. +/// +/// +/// On success the file is renamed *.imported (with its -wal/-shm +/// siblings) so the next boot does no work. Overwriting a stale *.imported is +/// deliberate: the newer file is the one that was just imported, and keeping the older copy +/// would make the next boot's rename fail forever. +/// +/// +/// No failure here takes the node down. Every exception — import or rename — is logged +/// at Error and swallowed: keys already in the shared store keep authenticating, and the +/// file is left in place so the next boot retries. A host that refused to start on a +/// half-reachable database would turn a recoverable data-migration problem into an outage. +/// +/// +/// Registered AFTER AddZbApiKeyAuthSqlServer in Program.cs so it starts after +/// the library's own migration hosted service (hosted services start in registration order) +/// and the zbauth schema therefore exists before the first insert. +/// +/// +/// The SQLite → shared-store copier. +/// Host-side key-store selector (provider + optional explicit import path). +/// The library's API-key options, whose SqlitePath is the default source. +/// Logger for the import outcome. +internal sealed class InboundApiKeyStoreImportService( + IInboundApiKeyStoreImporter importer, + IOptions storeOptions, + IOptions apiKeyOptions, + ILogger logger) : IHostedService +{ + /// SQL Server "Violation of PRIMARY KEY constraint" — the only error worth one retry. + private const int PrimaryKeyViolation = 2627; + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + var configured = storeOptions.Value.ImportFromSqlitePath; + var path = string.IsNullOrWhiteSpace(configured) ? apiKeyOptions.Value.SqlitePath : configured; + + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + logger.LogDebug( + "Inbound API key store: no SQLite key store to import (path {Path}).", + string.IsNullOrWhiteSpace(path) ? "" : path); + return; + } + + try + { + var result = await ImportWithOneRetryAsync(path, cancellationToken).ConfigureAwait(false); + + logger.LogInformation( + "Inbound API key store: imported {Path} into the shared SQL Server store — " + + "{KeysInserted} keys inserted, {KeysSkipped} keys already present, " + + "{AuditInserted} audit rows inserted, {AuditSkipped} audit rows already present, " + + "source found: {SourceFound}.", + path, result.KeysInserted, result.KeysSkipped, + result.AuditInserted, result.AuditSkipped, result.SourceFound); + + RenameImported(path); + } + catch (Exception ex) + { + // Deliberately broad and deliberately not rethrown — see the class remarks. + logger.LogError( + ex, + "Inbound API key store: importing {Path} into the shared SQL Server store failed. " + + "Keys already in the shared store keep working; the file is left in place and the " + + "next boot will retry.", + path); + } + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private async Task ImportWithOneRetryAsync(string path, CancellationToken ct) + { + try + { + return await importer.ImportAsync(path, ct).ConfigureAwait(false); + } + catch (SqlException ex) when (ex.Number == PrimaryKeyViolation) + { + logger.LogWarning( + ex, + "Inbound API key store: importing {Path} hit a primary-key violation — the peer node " + + "is most likely importing its own file concurrently. Retrying once; the second pass " + + "sees the peer's rows and skips them.", + path); + + return await importer.ImportAsync(path, ct).ConfigureAwait(false); + } + } + + /// + /// Renames the imported file and its WAL/SHM siblings to *.imported so the next boot is a + /// no-op, overwriting any stale copy left by an earlier run. + /// + /// The file that was just imported. + private static void RenameImported(string path) + { + foreach (var suffix in new[] { string.Empty, "-wal", "-shm" }) + { + var source = path + suffix; + if (File.Exists(source)) + { + File.Move(source, source + ".imported", overwrite: true); + } + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index a4b36581..15dc6e54 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection; +using ZB.MOM.WW.Auth.ApiKeys.SqlServer.DependencyInjection; using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.Health; using ZB.MOM.WW.Health.Akka; @@ -434,6 +435,14 @@ try // value already supplied via appsettings/env wins (AddInMemoryCollection // is registered last, but only fills keys the operator did not set // because we read the existing value first). + // + // Which BACKEND holds those keys is chosen by + // ScadaBridge:InboundApi:ApiKeyStore:Provider (InboundApiKeyStoreOptions): + // Sqlite (default) is the per-node file described above; SqlServer shares one + // row set across the central pair, so a key minted on either node survives a + // failover (design 2026-09-02, deferred-work register row 108). SqlitePath is + // still bound under SqlServer — it names the legacy per-node file that + // InboundApiKeyStoreImportService copies in once. const string apiKeyStoreSection = "ScadaBridge:InboundApi:ApiKeyStore"; var configuredSqlitePath = builder.Configuration[$"{apiKeyStoreSection}:SqlitePath"]; var apiKeyStoreDefaults = new Dictionary @@ -447,7 +456,36 @@ try }; builder.Configuration.AddInMemoryCollection(apiKeyStoreDefaults); - builder.Services.AddZbApiKeyAuth(builder.Configuration, apiKeyStoreSection); + var apiKeyStoreProvider = InboundApiKeyStoreOptions.ReadProvider(builder.Configuration); + builder.Services.Configure( + builder.Configuration.GetSection(InboundApiKeyStoreOptions.SectionPath)); + if (apiKeyStoreProvider == InboundApiKeyStoreProvider.SqlServer) + { + // Shared store for a central pair (design 2026-09-02, register row 108). The connection + // string defaults to the configuration database — the pair already shares it — under + // the library's own `zbauth` schema, so no new database or secret is needed. It must be + // a literal or an env override, NEVER a ${secret:} reference: the secrets store is + // itself SQL-backed and resolves before this point, so a secret-backed key-store string + // would be circular. + const string sqlSection = apiKeyStoreSection + ":SqlServer"; + if (string.IsNullOrWhiteSpace(builder.Configuration[$"{sqlSection}:ConnectionString"])) + { + builder.Configuration.AddInMemoryCollection(new Dictionary + { + [$"{sqlSection}:ConnectionString"] = builder.Configuration["ScadaBridge:Database:ConfigurationDb"], + }); + } + + builder.Services.AddZbApiKeyAuthSqlServer(builder.Configuration, apiKeyStoreSection, sqlSection); + builder.Services.AddSingleton(); + // Registered AFTER AddZbApiKeyAuthSqlServer so it starts after the library's migration + // hosted service (hosted services start in registration order) — the schema exists first. + builder.Services.AddHostedService(); + } + else + { + builder.Services.AddZbApiKeyAuth(builder.Configuration, apiKeyStoreSection); + } // Inbound-API key re-arch, additive: expose the library admin facade // (ApiKeyAdminCommands) and the app-side management seam (IInboundApiKeyAdmin) @@ -463,7 +501,7 @@ try sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService(), + sp.GetRequiredService(), TimeProvider.System)); builder.Services.AddSingleton< ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security.IInboundApiKeyAdmin, diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs index 53d744d0..00705069 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs @@ -364,6 +364,26 @@ public class CentralCompositionRootTests : IDisposable /// site writer chain is registered on central but by design never resolved there, and /// events written to it would dead-end in a local file no forwarder ever drains. /// + /// + /// Register row 108: the inbound API-key store is a per-node SQLite file UNLESS + /// ScadaBridge:InboundApi:ApiKeyStore:Provider says SqlServer. This pins the + /// default half — an unset Provider must leave the existing SQLite composition byte-identical, + /// including registering NO import hosted service (the one-shot SQLite→SQL copy is meaningless, + /// and would rename the live key file out from under the node, on a SQLite-backed node). + /// + [Fact] + public void Central_InboundApiKeyStore_DefaultsToSqlite() + { + Assert.IsType( + _factory.Services.GetService()); + Assert.IsType( + _factory.Services.GetService()); + + var hostedServices = _factory.Services.GetServices(); + Assert.DoesNotContain(hostedServices, s => s is InboundApiKeyStoreImportService); + Assert.Null(_factory.Services.GetService()); + } + [Fact] public void Central_SharedAuditSeam_IsTheCentralDirectWriteBridge() { @@ -373,6 +393,214 @@ public class CentralCompositionRootTests : IDisposable } } +/// +/// Register row 108 — the shared-store half of the inbound API-key composition. With +/// ScadaBridge:InboundApi:ApiKeyStore:Provider = SqlServer both central nodes must resolve +/// the SQL Server-backed stores (one shared row set) instead of each node's own SQLite file, so a +/// key minted on one node authenticates on the other after a failover. +/// +/// +/// +/// The Provider (and, in the derived fixture, the explicit connection string) is supplied as +/// a process-wide ENVIRONMENT variable rather than through +/// ConfigureAppConfiguration: Program reads this key while it is still +/// registering services, and only the pre-host configuration builder — json + environment — +/// is populated at that point. Same reason supplies +/// its values that way, and the same reason this class must sit in +/// . +/// +/// +/// There is no SQL Server in a unit test, so the two hosted services that would dial one at +/// host start are removed: the library's own migration service (which creates the +/// zbauth schema) and . Everything under +/// test is a registration shape, which needs no live database — every store is resolved, +/// never called. +/// +/// +[Collection(HostBootCollection.Name)] +public class CentralSqlServerKeyStoreCompositionRootTests : IDisposable +{ + /// Set by the derived fixture to pin an explicit connection string instead of the ConfigurationDb default. + private protected const string ExplicitConnectionString = + "Server=explicit-host,1433;Database=ExplicitKeyStore;Trusted_Connection=true;TrustServerCertificate=true"; + + private const string ProviderEnvKey = "ScadaBridge__InboundApi__ApiKeyStore__Provider"; + private const string ConnectionStringEnvKey = + "ScadaBridge__InboundApi__ApiKeyStore__SqlServer__ConnectionString"; + + private readonly WebApplicationFactory _factory; + private readonly string? _previousEnv; + private readonly string? _previousProvider; + private readonly string? _previousConnectionString; + private readonly CentralDbTestEnvironment _dbEnv; + + /// The provider the built host actually resolved services from. + private protected IServiceProvider Services => _factory.Services; + + private protected CentralSqlServerKeyStoreCompositionRootTests(string? explicitConnectionString) + { + _previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central"); + + _previousProvider = Environment.GetEnvironmentVariable(ProviderEnvKey); + Environment.SetEnvironmentVariable(ProviderEnvKey, "SqlServer"); + + _previousConnectionString = Environment.GetEnvironmentVariable(ConnectionStringEnvKey); + Environment.SetEnvironmentVariable(ConnectionStringEnvKey, explicitConnectionString); + + _dbEnv = new CentralDbTestEnvironment(); + + _factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ScadaBridge:Node:NodeName"] = "central-a", + ["ScadaBridge:Node:NodeHostname"] = "localhost", + ["ScadaBridge:Node:RemotingPort"] = "0", + ["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551", + ["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552", + ["ScadaBridge:Database:SkipMigrations"] = "true", + ["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!", + ["ScadaBridge:Security:Ldap:Server"] = "localhost", + ["ScadaBridge:Security:Ldap:Port"] = "3893", + ["ScadaBridge:Security:Ldap:Transport"] = "None", + ["ScadaBridge:Security:Ldap:AllowInsecure"] = "true", + ["ScadaBridge:Security:Ldap:SearchBase"] = "dc=zb,dc=local", + ["ScadaBridge:Security:Ldap:ServiceAccountDn"] = "cn=admin,dc=zb,dc=local", + ["ScadaBridge:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!", + }); + }); + builder.UseSetting("ScadaBridge:Node:Role", "Central"); + builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true"); + builder.ConfigureServices(services => + { + var descriptorsToRemove = services + .Where(d => + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(ScadaBridgeDbContext) || + d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true) + .ToList(); + foreach (var d in descriptorsToRemove) + services.Remove(d); + + services.AddDbContext(options => + options.UseInMemoryDatabase($"SqlServerKeyStoreCompositionRootTests_{Guid.NewGuid()}")); + + AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services); + + // No SQL Server here: drop the two hosted services that would dial one at + // host start. ApiKeyMigrationHostedService is internal to the Auth library, + // so it is matched by name. + var sqlDialingHostedServices = services + .Where(d => d.ServiceType == typeof(IHostedService) + && (d.ImplementationType?.Name == "ApiKeyMigrationHostedService" + || d.ImplementationType == typeof(InboundApiKeyStoreImportService))) + .ToList(); + foreach (var d in sqlDialingHostedServices) + services.Remove(d); + }); + }); + + _ = _factory.Server; + } + + public CentralSqlServerKeyStoreCompositionRootTests() + : this(explicitConnectionString: null) + { + } + + public void Dispose() + { + try + { + _factory.Dispose(); + } + finally + { + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv); + Environment.SetEnvironmentVariable(ProviderEnvKey, _previousProvider); + Environment.SetEnvironmentVariable(ConnectionStringEnvKey, _previousConnectionString); + _dbEnv.Dispose(); + } + } + + [Theory] + [InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStore), + typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyStore))] + [InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAdminStore), + typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyAdminStore))] + [InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAuditStore), + typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyAuditStore))] + [InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStoreMigrator), + typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyStoreMigrator))] + public void Central_SqlServerProvider_ResolvesSharedStores(Type serviceType, Type expectedImplementation) + { + var service = Services.GetService(serviceType); + Assert.NotNull(service); + Assert.IsType(expectedImplementation, service); + } + + /// + /// The connection string defaults to the configuration database the pair already shares, under + /// the library's own zbauth schema — so enabling the shared store needs no new database, + /// no new secret and no new appsettings key beyond the Provider switch. + /// + [Fact] + public virtual void Central_SqlServerProvider_ConnectionStringDefaultsToConfigurationDb() + { + var options = Services + .GetRequiredService>().Value; + var configuration = Services.GetRequiredService(); + + Assert.Equal(configuration["ScadaBridge:Database:ConfigurationDb"], options.ConnectionString); + Assert.False(string.IsNullOrWhiteSpace(options.ConnectionString)); + Assert.Equal("zbauth", options.Schema); + } + + /// + /// The admin facade must be composable against the shared store too — its migrator argument is + /// the provider-agnostic IApiKeyStoreMigrator, not the SQLite concrete type, which is + /// exactly the registration that would have thrown here had the switch been left half-done. + /// + [Fact] + public void Central_SqlServerProvider_ResolvesAdminCommandsAndImporter() + { + Assert.NotNull(Services.GetService()); + Assert.NotNull(Services.GetService()); + } +} + +/// +/// An operator who puts the shared key store in its own database sets +/// ScadaBridge:InboundApi:ApiKeyStore:SqlServer:ConnectionString explicitly; that value must +/// win over the ConfigurationDb default fill-in, which is only ever applied to a blank key. +/// +[Collection(HostBootCollection.Name)] +public sealed class CentralSqlServerKeyStoreExplicitConnectionStringTests + : CentralSqlServerKeyStoreCompositionRootTests +{ + public CentralSqlServerKeyStoreExplicitConnectionStringTests() + : base(ExplicitConnectionString) + { + } + + /// + [Fact] + public override void Central_SqlServerProvider_ConnectionStringDefaultsToConfigurationDb() + { + var options = Services + .GetRequiredService>().Value; + var configuration = Services.GetRequiredService(); + + Assert.Equal(ExplicitConnectionString, options.ConnectionString); + Assert.NotEqual(configuration["ScadaBridge:Database:ConfigurationDb"], options.ConnectionString); + } +} + /// /// Verifies every expected DI service resolves from the Site composition root. /// Uses the extracted SiteServiceRegistration.Configure() so the test always diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreImportServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreImportServiceTests.cs new file mode 100644 index 00000000..4b2e5f61 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreImportServiceTests.cs @@ -0,0 +1,262 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.Auth.Abstractions.ApiKeys; +using ZB.MOM.WW.Auth.ApiKeys.SqlServer; +using ZB.MOM.WW.ScadaBridge.Host; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// Unit tests for — the one-shot copy of a node's +/// legacy per-node SQLite key file into the shared SQL Server store (register row 108). +/// The real importer is replaced by so every case runs without a +/// SQL Server: the behaviour under test is path selection, the rename-on-success, the single +/// PK-violation retry, and the "never take the node down" exception posture. +/// +public sealed class InboundApiKeyStoreImportServiceTests : IDisposable +{ + private readonly string _dir; + + public InboundApiKeyStoreImportServiceTests() + { + _dir = Path.Combine(Path.GetTempPath(), $"sb_apikey_import_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_dir); + } + + public void Dispose() + { + try + { + Directory.Delete(_dir, recursive: true); + } + catch (IOException) + { + // Best-effort temp cleanup; a locked file must not fail the suite. + } + } + + [Fact] + public async Task StartAsync_FilePresent_ImportsThenRenamesFileAndSidecars() + { + var path = WriteFile("inbound-api-keys.sqlite"); + WriteFile("inbound-api-keys.sqlite-wal"); + WriteFile("inbound-api-keys.sqlite-shm"); + + var importer = new FakeImporter(); + var service = CreateService(importer, sqlitePath: path); + + await service.StartAsync(CancellationToken.None); + + Assert.Equal(new[] { path }, importer.Calls); + Assert.False(File.Exists(path)); + Assert.True(File.Exists(path + ".imported")); + Assert.False(File.Exists(path + "-wal")); + Assert.True(File.Exists(path + "-wal.imported")); + Assert.False(File.Exists(path + "-shm")); + Assert.True(File.Exists(path + "-shm.imported")); + } + + [Fact] + public async Task StartAsync_FileAbsent_DoesNotCallImporter() + { + var importer = new FakeImporter(); + var service = CreateService(importer, sqlitePath: Path.Combine(_dir, "missing.sqlite")); + + await service.StartAsync(CancellationToken.None); + + Assert.Empty(importer.Calls); + } + + [Fact] + public async Task StartAsync_BlankPath_DoesNotCallImporter() + { + var importer = new FakeImporter(); + var service = CreateService(importer, sqlitePath: " "); + + await service.StartAsync(CancellationToken.None); + + Assert.Empty(importer.Calls); + } + + [Fact] + public async Task StartAsync_ImportFromSqlitePath_OverridesApiKeyOptionsPath() + { + var apiKeyPath = WriteFile("api-key-options.sqlite"); + var overridePath = WriteFile("explicit-override.sqlite"); + + var importer = new FakeImporter(); + var service = CreateService(importer, sqlitePath: apiKeyPath, importFrom: overridePath); + + await service.StartAsync(CancellationToken.None); + + Assert.Equal(new[] { overridePath }, importer.Calls); + Assert.True(File.Exists(apiKeyPath)); // untouched + Assert.True(File.Exists(overridePath + ".imported")); + } + + [Fact] + public async Task StartAsync_ImporterThrows_DoesNotThrowAndLeavesFileInPlace() + { + var path = WriteFile("inbound-api-keys.sqlite"); + + var importer = new FakeImporter { ThrowOnCall = _ => new InvalidOperationException("boom") }; + var service = CreateService(importer, sqlitePath: path); + + await service.StartAsync(CancellationToken.None); + + Assert.Single(importer.Calls); + Assert.True(File.Exists(path)); // left for the next boot to retry + Assert.False(File.Exists(path + ".imported")); + } + + [Fact] + public async Task StartAsync_PrimaryKeyViolation_RetriesOnceThenSucceeds() + { + var path = WriteFile("inbound-api-keys.sqlite"); + + var importer = new FakeImporter + { + ThrowOnCall = call => call == 1 ? FabricateSqlException("duplicate key", number: 2627) : null, + }; + var service = CreateService(importer, sqlitePath: path); + + await service.StartAsync(CancellationToken.None); + + Assert.Equal(2, importer.Calls.Count); + Assert.True(File.Exists(path + ".imported")); + } + + [Fact] + public async Task StartAsync_PrimaryKeyViolationTwice_DoesNotThrowAndLeavesFileInPlace() + { + var path = WriteFile("inbound-api-keys.sqlite"); + + var importer = new FakeImporter + { + ThrowOnCall = _ => FabricateSqlException("duplicate key", number: 2627), + }; + var service = CreateService(importer, sqlitePath: path); + + await service.StartAsync(CancellationToken.None); + + Assert.Equal(2, importer.Calls.Count); + Assert.True(File.Exists(path)); + } + + [Fact] + public async Task StartAsync_NonPrimaryKeySqlException_DoesNotRetry() + { + var path = WriteFile("inbound-api-keys.sqlite"); + + var importer = new FakeImporter + { + ThrowOnCall = _ => FabricateSqlException("login failed", number: 18456), + }; + var service = CreateService(importer, sqlitePath: path); + + await service.StartAsync(CancellationToken.None); + + Assert.Single(importer.Calls); + Assert.True(File.Exists(path)); + } + + [Fact] + public async Task StartAsync_StaleImportedFilePresent_IsOverwritten() + { + var path = WriteFile("inbound-api-keys.sqlite", content: "fresh"); + WriteFile("inbound-api-keys.sqlite.imported", content: "stale"); + + var importer = new FakeImporter(); + var service = CreateService(importer, sqlitePath: path); + + await service.StartAsync(CancellationToken.None); + + Assert.False(File.Exists(path)); + Assert.Equal("fresh", File.ReadAllText(path + ".imported")); + } + + [Fact] + public async Task StopAsync_IsANoOp() + { + var importer = new FakeImporter(); + var service = CreateService(importer, sqlitePath: Path.Combine(_dir, "missing.sqlite")); + + await service.StopAsync(CancellationToken.None); + + Assert.Empty(importer.Calls); + } + + // --- helpers --- + + private string WriteFile(string name, string content = "x") + { + var path = Path.Combine(_dir, name); + File.WriteAllText(path, content); + return path; + } + + private static InboundApiKeyStoreImportService CreateService( + IInboundApiKeyStoreImporter importer, string sqlitePath, string? importFrom = null) + => new( + importer, + Options.Create(new InboundApiKeyStoreOptions + { + Provider = InboundApiKeyStoreProvider.SqlServer, + ImportFromSqlitePath = importFrom, + }), + Options.Create(new ApiKeyOptions { SqlitePath = sqlitePath }), + NullLogger.Instance); + + private sealed class FakeImporter : IInboundApiKeyStoreImporter + { + private int _calls; + + public List Calls { get; } = new(); + + /// Given the 1-based call number, returns the exception to throw, or null to succeed. + public Func? ThrowOnCall { get; init; } + + public Task ImportAsync(string sqlitePath, CancellationToken ct) + { + Calls.Add(sqlitePath); + var call = ++_calls; + var toThrow = ThrowOnCall?.Invoke(call); + if (toThrow is not null) + { + return Task.FromException(toThrow); + } + + return Task.FromResult(new ApiKeyImportResult(1, 0, 2, 0, SourceFound: true)); + } + } + + /// + /// Fabricates a with a given message and + /// error number via the driver's internal CreateException factory (the type has no + /// public constructor). Same recipe as DatabaseGatewayTests.FabricateSqlException; + /// duplicated because the two suites share no test-support assembly. + /// + private static Microsoft.Data.SqlClient.SqlException FabricateSqlException(string message, int number) + { + var errorCtor = typeof(Microsoft.Data.SqlClient.SqlError).GetConstructors( + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + .First(c => c.GetParameters().Length == 8 + && c.GetParameters()[7].ParameterType == typeof(Exception)); + var sqlError = (Microsoft.Data.SqlClient.SqlError)errorCtor.Invoke( + new object?[] { number, (byte)0, (byte)0, "server", message, "procedure", 0, null }); + + var collectionCtor = typeof(Microsoft.Data.SqlClient.SqlErrorCollection).GetConstructors( + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).First(); + var collection = (Microsoft.Data.SqlClient.SqlErrorCollection)collectionCtor.Invoke(Array.Empty()); + typeof(Microsoft.Data.SqlClient.SqlErrorCollection) + .GetMethod("Add", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .Invoke(collection, new object?[] { sqlError }); + + var createException = typeof(Microsoft.Data.SqlClient.SqlException).GetMethod( + "CreateException", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic, + new[] { typeof(Microsoft.Data.SqlClient.SqlErrorCollection), typeof(string) })!; + return (Microsoft.Data.SqlClient.SqlException)createException.Invoke( + null, new object?[] { collection, "6.0.0" })!; + } +}