feat(host): inbound API key store provider — SqlServer shares one zbauth row set across the central pair; one-shot SQLite import at boot (file renamed .imported, one retry on a concurrent-peer PK race, failure never blocks boot)

Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
This commit is contained in:
Joseph Doherty
2026-09-02 23:26:03 -04:00
parent afe59a5518
commit 06be0b9e64
4 changed files with 695 additions and 2 deletions
@@ -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;
/// <summary>
/// Seam over <see cref="SqliteApiKeyStoreImporter"/> — the library type is sealed and has no
/// interface, so this is what lets <see cref="InboundApiKeyStoreImportService"/> be unit-tested
/// without a SQL Server.
/// </summary>
internal interface IInboundApiKeyStoreImporter
{
/// <summary>Copies every key and audit row from the SQLite file at <paramref name="sqlitePath"/> into the shared store.</summary>
/// <param name="sqlitePath">Path of the per-node SQLite API-key file.</param>
/// <param name="ct">A token to cancel the import.</param>
/// <returns>Per-category insert/skip counts.</returns>
Task<ApiKeyImportResult> ImportAsync(string sqlitePath, CancellationToken ct);
}
/// <summary>The production seam implementation: a straight delegation to the library importer.</summary>
/// <param name="inner">The library importer, resolved from the SQL Server API-key registration.</param>
internal sealed class LibraryInboundApiKeyStoreImporter(SqliteApiKeyStoreImporter inner)
: IInboundApiKeyStoreImporter
{
/// <inheritdoc/>
public Task<ApiKeyImportResult> ImportAsync(string sqlitePath, CancellationToken ct)
=> inner.ImportAsync(sqlitePath, ct);
}
/// <summary>
/// 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
/// <c>ScadaBridge:InboundApi:ApiKeyStore:Provider = SqlServer</c> (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.
/// </summary>
/// <remarks>
/// <para>
/// The source is <see cref="InboundApiKeyStoreOptions.ImportFromSqlitePath"/> when set,
/// otherwise the library's own <see cref="ApiKeyOptions.SqlitePath"/> — which still points
/// at the node's pre-existing file even under the SQL Server provider, since
/// <c>AddZbApiKeyAuthSqlServer</c> keeps that option bound. A missing file is the normal
/// steady state and costs nothing.
/// </para>
/// <para>
/// 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 <c>2627</c>) — 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.
/// </para>
/// <para>
/// On success the file is renamed <c>*.imported</c> (with its <c>-wal</c>/<c>-shm</c>
/// siblings) so the next boot does no work. Overwriting a stale <c>*.imported</c> 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.
/// </para>
/// <para>
/// <b>No failure here takes the node down.</b> 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.
/// </para>
/// <para>
/// Registered AFTER <c>AddZbApiKeyAuthSqlServer</c> in <c>Program.cs</c> so it starts after
/// the library's own migration hosted service (hosted services start in registration order)
/// and the <c>zbauth</c> schema therefore exists before the first insert.
/// </para>
/// </remarks>
/// <param name="importer">The SQLite → shared-store copier.</param>
/// <param name="storeOptions">Host-side key-store selector (provider + optional explicit import path).</param>
/// <param name="apiKeyOptions">The library's API-key options, whose <c>SqlitePath</c> is the default source.</param>
/// <param name="logger">Logger for the import outcome.</param>
internal sealed class InboundApiKeyStoreImportService(
IInboundApiKeyStoreImporter importer,
IOptions<InboundApiKeyStoreOptions> storeOptions,
IOptions<ApiKeyOptions> apiKeyOptions,
ILogger<InboundApiKeyStoreImportService> logger) : IHostedService
{
/// <summary>SQL Server "Violation of PRIMARY KEY constraint" — the only error worth one retry.</summary>
private const int PrimaryKeyViolation = 2627;
/// <inheritdoc/>
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) ? "<unset>" : 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);
}
}
/// <inheritdoc/>
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private async Task<ApiKeyImportResult> 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);
}
}
/// <summary>
/// Renames the imported file and its WAL/SHM siblings to <c>*.imported</c> so the next boot is a
/// no-op, overwriting any stale copy left by an earlier run.
/// </summary>
/// <param name="path">The file that was just imported.</param>
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);
}
}
}
}
+40 -2
View File
@@ -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<string, string?>
@@ -447,7 +456,36 @@ try
};
builder.Configuration.AddInMemoryCollection(apiKeyStoreDefaults);
builder.Services.AddZbApiKeyAuth(builder.Configuration, apiKeyStoreSection);
var apiKeyStoreProvider = InboundApiKeyStoreOptions.ReadProvider(builder.Configuration);
builder.Services.Configure<InboundApiKeyStoreOptions>(
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<string, string?>
{
[$"{sqlSection}:ConnectionString"] = builder.Configuration["ScadaBridge:Database:ConfigurationDb"],
});
}
builder.Services.AddZbApiKeyAuthSqlServer(builder.Configuration, apiKeyStoreSection, sqlSection);
builder.Services.AddSingleton<IInboundApiKeyStoreImporter, LibraryInboundApiKeyStoreImporter>();
// 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<InboundApiKeyStoreImportService>();
}
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<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAdminStore>(),
sp.GetRequiredService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAuditStore>(),
sp.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.IApiKeyPepperProvider>(),
sp.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthStoreMigrator>(),
sp.GetRequiredService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStoreMigrator>(),
TimeProvider.System));
builder.Services.AddSingleton<
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security.IInboundApiKeyAdmin,
@@ -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.
/// </summary>
/// <summary>
/// Register row 108: the inbound API-key store is a per-node SQLite file UNLESS
/// <c>ScadaBridge:InboundApi:ApiKeyStore:Provider</c> says <c>SqlServer</c>. 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).
/// </summary>
[Fact]
public void Central_InboundApiKeyStore_DefaultsToSqlite()
{
Assert.IsType<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteApiKeyStore>(
_factory.Services.GetService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStore>());
Assert.IsType<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthStoreMigrator>(
_factory.Services.GetService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStoreMigrator>());
var hostedServices = _factory.Services.GetServices<IHostedService>();
Assert.DoesNotContain(hostedServices, s => s is InboundApiKeyStoreImportService);
Assert.Null(_factory.Services.GetService<IInboundApiKeyStoreImporter>());
}
[Fact]
public void Central_SharedAuditSeam_IsTheCentralDirectWriteBridge()
{
@@ -373,6 +393,214 @@ public class CentralCompositionRootTests : IDisposable
}
}
/// <summary>
/// Register row 108 — the shared-store half of the inbound API-key composition. With
/// <c>ScadaBridge:InboundApi:ApiKeyStore:Provider = SqlServer</c> 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.
/// </summary>
/// <remarks>
/// <para>
/// The Provider (and, in the derived fixture, the explicit connection string) is supplied as
/// a process-wide ENVIRONMENT variable rather than through
/// <c>ConfigureAppConfiguration</c>: <c>Program</c> 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 <see cref="CentralDbTestEnvironment"/> supplies
/// its values that way, and the same reason this class must sit in
/// <see cref="HostBootCollection"/>.
/// </para>
/// <para>
/// 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
/// <c>zbauth</c> schema) and <see cref="InboundApiKeyStoreImportService"/>. Everything under
/// test is a registration shape, which needs no live database — every store is resolved,
/// never called.
/// </para>
/// </remarks>
[Collection(HostBootCollection.Name)]
public class CentralSqlServerKeyStoreCompositionRootTests : IDisposable
{
/// <summary>Set by the derived fixture to pin an explicit connection string instead of the ConfigurationDb default.</summary>
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<Program> _factory;
private readonly string? _previousEnv;
private readonly string? _previousProvider;
private readonly string? _previousConnectionString;
private readonly CentralDbTestEnvironment _dbEnv;
/// <summary>The provider the built host actually resolved services from.</summary>
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<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<ScadaBridgeDbContext>) ||
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<ScadaBridgeDbContext>(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);
}
/// <summary>
/// The connection string defaults to the configuration database the pair already shares, under
/// the library's own <c>zbauth</c> schema — so enabling the shared store needs no new database,
/// no new secret and no new appsettings key beyond the Provider switch.
/// </summary>
[Fact]
public virtual void Central_SqlServerProvider_ConnectionStringDefaultsToConfigurationDb()
{
var options = Services
.GetRequiredService<IOptions<ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyOptions>>().Value;
var configuration = Services.GetRequiredService<IConfiguration>();
Assert.Equal(configuration["ScadaBridge:Database:ConfigurationDb"], options.ConnectionString);
Assert.False(string.IsNullOrWhiteSpace(options.ConnectionString));
Assert.Equal("zbauth", options.Schema);
}
/// <summary>
/// The admin facade must be composable against the shared store too — its migrator argument is
/// the provider-agnostic <c>IApiKeyStoreMigrator</c>, not the SQLite concrete type, which is
/// exactly the registration that would have thrown here had the switch been left half-done.
/// </summary>
[Fact]
public void Central_SqlServerProvider_ResolvesAdminCommandsAndImporter()
{
Assert.NotNull(Services.GetService<ZB.MOM.WW.Auth.ApiKeys.Admin.ApiKeyAdminCommands>());
Assert.NotNull(Services.GetService<IInboundApiKeyStoreImporter>());
}
}
/// <summary>
/// An operator who puts the shared key store in its own database sets
/// <c>ScadaBridge:InboundApi:ApiKeyStore:SqlServer:ConnectionString</c> explicitly; that value must
/// win over the ConfigurationDb default fill-in, which is only ever applied to a blank key.
/// </summary>
[Collection(HostBootCollection.Name)]
public sealed class CentralSqlServerKeyStoreExplicitConnectionStringTests
: CentralSqlServerKeyStoreCompositionRootTests
{
public CentralSqlServerKeyStoreExplicitConnectionStringTests()
: base(ExplicitConnectionString)
{
}
/// <inheritdoc/>
[Fact]
public override void Central_SqlServerProvider_ConnectionStringDefaultsToConfigurationDb()
{
var options = Services
.GetRequiredService<IOptions<ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyOptions>>().Value;
var configuration = Services.GetRequiredService<IConfiguration>();
Assert.Equal(ExplicitConnectionString, options.ConnectionString);
Assert.NotEqual(configuration["ScadaBridge:Database:ConfigurationDb"], options.ConnectionString);
}
}
/// <summary>
/// Verifies every expected DI service resolves from the Site composition root.
/// Uses the extracted SiteServiceRegistration.Configure() so the test always
@@ -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;
/// <summary>
/// Unit tests for <see cref="InboundApiKeyStoreImportService"/> — 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 <see cref="FakeImporter"/> 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.
/// </summary>
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<InboundApiKeyStoreImportService>.Instance);
private sealed class FakeImporter : IInboundApiKeyStoreImporter
{
private int _calls;
public List<string> Calls { get; } = new();
/// <summary>Given the 1-based call number, returns the exception to throw, or null to succeed.</summary>
public Func<int, Exception?>? ThrowOnCall { get; init; }
public Task<ApiKeyImportResult> ImportAsync(string sqlitePath, CancellationToken ct)
{
Calls.Add(sqlitePath);
var call = ++_calls;
var toThrow = ThrowOnCall?.Invoke(call);
if (toThrow is not null)
{
return Task.FromException<ApiKeyImportResult>(toThrow);
}
return Task.FromResult(new ApiKeyImportResult(1, 0, 2, 0, SourceFound: true));
}
}
/// <summary>
/// Fabricates a <see cref="Microsoft.Data.SqlClient.SqlException"/> with a given message and
/// error number via the driver's internal <c>CreateException</c> factory (the type has no
/// public constructor). Same recipe as <c>DatabaseGatewayTests.FabricateSqlException</c>;
/// duplicated because the two suites share no test-support assembly.
/// </summary>
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<object?>());
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" })!;
}
}