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