feat(secrets): Secrets:Store:Provider=SqlServer — central pair shares one SQL secret store without the hub
This commit is contained in:
@@ -196,6 +196,77 @@ public class SecretsReplicationWiringTests
|
||||
Assert.False(SecretsRegistration.UsesGrpcHub(config));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Secrets:Store:Provider=SqlServer — a clustered central sharing ONE SQL secret store
|
||||
// directly, without standing up the gRPC hub (task 5). Independent of Replication/Mode.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Central_StoreProviderSqlServer_UsesSharedSqlStore_WithoutHub()
|
||||
{
|
||||
IConfiguration config = BuildConfig(
|
||||
("Secrets:Store:Provider", "SqlServer"),
|
||||
("Secrets:SqlServer:ConnectionString", DummyConnectionString));
|
||||
|
||||
using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Central);
|
||||
|
||||
Assert.IsType<SqlServerSecretStore>(provider.GetRequiredService<ISecretStore>());
|
||||
|
||||
// No hub: neither the fail-closed bearer interceptor nor the follower sweep is wired,
|
||||
// and UsesGrpcHub — the single predicate MapScadaBridgeSecretsHub also checks — is false.
|
||||
Assert.Null(provider.GetService<SecretsHubAuthInterceptor>());
|
||||
Assert.DoesNotContain(provider.GetServices<IHostedService>(), IsGrpcHubSweepService);
|
||||
Assert.False(SecretsRegistration.UsesGrpcHub(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_StoreProviderSqlServer_BlankConnectionString_Throws()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer"));
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central));
|
||||
|
||||
// Same helper, same message style as the Grpc-mode central check.
|
||||
Assert.Contains(
|
||||
SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_StoreProviderSqlServer_SecretReferenceConnectionString_Throws()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
IConfiguration config = BuildConfig(
|
||||
("Secrets:Store:Provider", "SqlServer"),
|
||||
("Secrets:SqlServer:ConnectionString", "${secret:hub-connection-string}"));
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => services.AddScadaBridgeSecrets(config, SecretsNodeRole.Central));
|
||||
|
||||
Assert.Contains(
|
||||
SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("${secret:", ex.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("hub-connection-string", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_IgnoresStoreProvider()
|
||||
{
|
||||
// A Site node ignores Secrets:Store:Provider entirely and always keeps its local SQLite
|
||||
// store — even with the value set to SqlServer and no connection string configured at
|
||||
// all, which would fail closed on Central.
|
||||
IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer"));
|
||||
|
||||
using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Site);
|
||||
|
||||
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// SQL-Server mode — regression pins. Behaviour must be byte-for-byte what it was before the
|
||||
// mode key existed, both when the mode is left unset and when it is named explicitly.
|
||||
|
||||
@@ -699,7 +699,11 @@ public class StartupValidatorTests
|
||||
{
|
||||
// Design 2026-09-02: the inbound API key store is per-node SQLite by default, so a
|
||||
// 2+-node central cluster is one failover away from rejecting every inbound client.
|
||||
var config = BuildConfig(ValidCentralConfig());
|
||||
// Secrets:Store:Provider is set to SqlServer here to isolate this warning from the
|
||||
// independent one added for task 5 (ClusteredCentral_SqliteSecretStore_Warns below).
|
||||
var values = ValidCentralConfig();
|
||||
values["Secrets:Store:Provider"] = "SqlServer";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var warnings = StartupValidator.CollectWarnings(config);
|
||||
|
||||
@@ -714,6 +718,7 @@ public class StartupValidatorTests
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "sqlite";
|
||||
values["Secrets:Store:Provider"] = "SqlServer";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var warnings = StartupValidator.CollectWarnings(config);
|
||||
@@ -726,6 +731,39 @@ public class StartupValidatorTests
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "SqlServer";
|
||||
values["Secrets:Store:Provider"] = "SqlServer";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var warnings = StartupValidator.CollectWarnings(config);
|
||||
|
||||
Assert.Empty(warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClusteredCentral_SqliteSecretStore_Warns()
|
||||
{
|
||||
// Task 5: a clustered central left on the per-node SQLite secret store silently drifts
|
||||
// the moment anyone edits a secret on only one node, then ships an inconsistent set to
|
||||
// sites. ApiKeyStore:Provider is set to SqlServer here to isolate this warning from the
|
||||
// pre-existing one above.
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "SqlServer";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var warnings = StartupValidator.CollectWarnings(config);
|
||||
|
||||
var warning = Assert.Single(warnings);
|
||||
Assert.Contains("clustered central", warning);
|
||||
Assert.Contains("SQLite", warning);
|
||||
Assert.Contains("Secrets:Store:Provider=SqlServer", warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClusteredCentral_SqlServerSecretStore_Silent()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "SqlServer";
|
||||
values["Secrets:Store:Provider"] = "SqlServer";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var warnings = StartupValidator.CollectWarnings(config);
|
||||
@@ -779,6 +817,28 @@ public class StartupValidatorTests
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownSecretStoreProvider_Fails()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["Secrets:Store:Provider"] = "Postgres";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("Secrets:Store:Provider", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsSecretStoreProviderCaseInsensitively()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["Secrets:Store:Provider"] = "sqlserver";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleErrors_AllReported()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user