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
@@ -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