Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsReplicationWiringTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

359 lines
18 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.SqlServer;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// DI-resolution tests for <see cref="SecretsRegistration.AddScadaBridgeSecrets"/>.
/// <para>
/// Every secrets REPLICATION mode is retired in ScadaBridge — the pull-only gRPC hub (central
/// hosts, sites sweep) and the SQL-Server <c>ReplicatingSecretStore</c> mode that preceded it — on
/// BOTH roles. What is left: the plain local SQLite store, which is every node's default and now a
/// Site's ONLY possible shape, and the still-supported
/// <see cref="SecretsRegistration.StoreProviderKey"/>=SqlServer direct shared store (Central-only,
/// no hub, no follower sweep — just two nodes reading and writing one database).
/// </para>
/// <para>
/// These deliberately BUILD a container and RESOLVE out of it rather than asserting over
/// <see cref="ServiceDescriptor"/>s. A decorator registration can look perfectly correct as a
/// descriptor list and still throw on the first resolve because the undecorated concrete store it
/// depends on is missing — that exact gap shipped once in this library, with every descriptor-level
/// unit test green. Resolution is the only assertion that catches it.
/// </para>
/// <para>
/// Registration must not need a reachable SQL Server: the SQL-Server package validates its options
/// eagerly, but validation only checks that the connection string is non-empty and the schema name
/// is a legal identifier. Nothing connects until a write or read runs, and these tests do neither —
/// so the dummy connection string below never resolves to a real host.
/// </para>
/// </summary>
public class SecretsReplicationWiringTests
{
// Syntactically valid, deliberately unreachable. Never connected to by these tests.
private const string DummyConnectionString =
"Server=unused;Database=x;Integrated Security=true;";
/// <summary>
/// A fresh directory, never the one holding <see cref="BuildConfig"/>'s default
/// <c>Secrets:SqlitePath</c> (<c>Path.GetTempPath()</c> itself) — the SQLite path-rules
/// validator runs eagerly on the first <c>IOptions&lt;SecretsOptions&gt;.Value</c> read (i.e.
/// on resolving <c>ISecretStore</c> itself, not merely on migrating), so passing
/// <c>Path.GetTempPath()</c> as the content root here would make every SQLite-path expander
/// test fail on "path is inside the content root" instead of testing what it means to.
/// </summary>
private static string NewContentRoot() =>
Path.Combine(Path.GetTempPath(), "sb-secrets-wiring-content-root-" + Guid.NewGuid().ToString("N"));
private static IConfiguration BuildConfig(params (string Key, string Value)[] overrides)
{
var settings = new Dictionary<string, string?>
{
["Secrets:SqlitePath"] = Path.Combine(Path.GetTempPath(), "sb-secrets-wiring-test.db"),
["Secrets:MasterKey:Source"] = "Environment",
["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_MASTER_KEY",
["Secrets:RunMigrationsOnStartup"] = "false",
};
foreach ((string key, string value) in overrides)
{
settings[key] = value;
}
return new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
}
private static ServiceProvider BuildProvider(
IConfiguration config,
SecretsNodeRole role = SecretsNodeRole.Central)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddScadaBridgeSecrets(config, role);
return services.BuildServiceProvider(validateScopes: true);
}
/// <summary>
/// Any descriptor whose service OR implementation type lives in the SQL-Server package.
/// Honest bound: the package also registers <c>ISecretStore</c>/<c>ISecretsStoreMigrator</c>
/// through factory lambdas whose service type is the Abstractions interface and whose
/// ImplementationType is null — those two descriptors would evade this scan in isolation. The
/// site-purity pin still holds because the same extension unconditionally registers concrete
/// types from the target assembly first, which this scan does catch — so the package cannot
/// enter the container without tripping it, even though not every individual descriptor it
/// adds is individually detectable.
/// </summary>
private static bool IsFromSqlServerPackage(ServiceDescriptor descriptor) =>
descriptor.ServiceType.Assembly.GetName().Name == "ZB.MOM.WW.Secrets.Replicator.SqlServer"
|| descriptor.ImplementationType?.Assembly.GetName().Name
== "ZB.MOM.WW.Secrets.Replicator.SqlServer";
// ---------------------------------------------------------------------------------------
// Site: the ONLY shape left, on every configuration — plain local SQLite, never a
// replication decorator. DeployedSecretsMirror deletes a name from
// this store directly the moment a deploy artifact stops referencing it; a decorator would
// publish that delete to every other node sharing whatever it decorates against, turning
// one site's unreferencing of a secret into a fleet-wide deletion.
// ---------------------------------------------------------------------------------------
/// <summary>Verifies that a site resolves the plain SQLite secret store and never a replication decorator.</summary>
[Fact]
public void Site_ResolvesPlainSqliteStore_NeverAReplicationDecorator()
{
using ServiceProvider provider = BuildProvider(BuildConfig(), SecretsNodeRole.Site);
var store = provider.GetRequiredService<ISecretStore>();
// Assert.IsType checks the EXACT runtime type, not merely "is a SqliteSecretStore or a
// decorator wrapping one" — nothing may sit between the site and its local file.
Assert.IsType<SqliteSecretStore>(store);
}
/// <summary>Verifies that a site ignores the store provider setting even when it is set to SqlServer.</summary>
[Fact]
public void Site_IgnoresStoreProvider_EvenSetToSqlServer()
{
// 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>());
}
/// <summary>Verifies that the site container holds no SQL Server secret-store package types.</summary>
[Fact]
public void Site_HasNoSqlServerPackageTypesInTheContainer()
{
// The sites-don't-talk-to-SQL pin: a site node's container must contain NOTHING from the
// SQL-Server package — not just resolve a SQLite store, but hold no descriptor that could
// ever open a connection to central's database.
var services = new ServiceCollection();
services.AddLogging();
services.AddScadaBridgeSecrets(BuildConfig(), SecretsNodeRole.Site);
Assert.DoesNotContain(services, IsFromSqlServerPackage);
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
Assert.Null(provider.GetService<SqlServerSecretStore>());
}
// ---------------------------------------------------------------------------------------
// Central, default — plain local SQLite, the shape every existing deployment is in.
// ---------------------------------------------------------------------------------------
/// <summary>Verifies that central defaults to the plain SQLite secret store.</summary>
[Fact]
public void Central_DefaultsToPlainSqliteStore()
{
using ServiceProvider provider = BuildProvider(BuildConfig());
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
}
/// <summary>Verifies that setting the store provider to Sqlite behaves the same as leaving it unset on central.</summary>
[Fact]
public void Central_StoreProviderSqlite_IsIndistinguishableFromUnset()
{
using ServiceProvider explicitProvider =
BuildProvider(BuildConfig(("Secrets:Store:Provider", "Sqlite")));
using ServiceProvider unsetProvider = BuildProvider(BuildConfig());
Assert.IsType<SqliteSecretStore>(explicitProvider.GetRequiredService<ISecretStore>());
Assert.IsType<SqliteSecretStore>(unsetProvider.GetRequiredService<ISecretStore>());
}
// ---------------------------------------------------------------------------------------
// Secrets:Store:Provider=SqlServer — a clustered central sharing ONE SQL secret store
// directly, without a hub. The one replication-adjacent feature that was left
// standing, because it is a plain shared database, not a converging replica set.
// ---------------------------------------------------------------------------------------
/// <summary>Verifies that central uses the shared SQL store when the provider is SqlServer.</summary>
[Fact]
public void Central_StoreProviderSqlServer_UsesSharedSqlStore()
{
IConfiguration config = BuildConfig(
("Secrets:Store:Provider", "SqlServer"),
("Secrets:SqlServer:ConnectionString", DummyConnectionString));
using ServiceProvider provider = BuildProvider(config, SecretsNodeRole.Central);
Assert.IsType<SqlServerSecretStore>(provider.GetRequiredService<ISecretStore>());
}
/// <summary>Verifies that central throws when the provider is SqlServer and the connection string is blank.</summary>
[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));
Assert.Contains(
SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
Assert.Contains(
$"{SecretsRegistration.StoreProviderKey}=SqlServer", ex.Message, StringComparison.Ordinal);
}
/// <summary>
/// The store's own connection string can never be a <c>${secret:}</c> reference — the pre-host
/// expander needs the store this string points at in order to resolve it (bootstrap
/// circularity). Rejected by name rather than left to fail as a malformed connection string on
/// first use.
/// </summary>
[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);
// The reference NAME is config, not secret material — but the message must still not echo
// the configured value, because a mistyped value here could be a pasted real credential.
Assert.DoesNotContain("hub-connection-string", ex.Message, StringComparison.Ordinal);
}
// ---------------------------------------------------------------------------------------
// The Layer-A pre-host ${secret:} expander (SecretsRegistration.AddPreHostExpander, called
// from Program.cs). It MUST compose against the SAME store
// AddScadaBridgeSecrets registers for the running node: an expander that resolved boot-time
// ${secret:} tokens (e.g. ScadaBridge:Security:JwtSigningKey) from the per-node SQLite file
// while the running node served the shared SQL store would let two central nodes disagree on
// a rotated value — exactly the per-node drift Store:Provider=SqlServer exists to prevent.
// UsesCentralSharedStore is the single predicate shared by both methods.
// ---------------------------------------------------------------------------------------
/// <summary>Verifies that the shared-store check is true only for central with the SqlServer provider.</summary>
[Fact]
public void UsesCentralSharedStore_TrueOnlyForCentralWithProviderSqlServer()
{
IConfiguration sqlServerConfig = BuildConfig(("Secrets:Store:Provider", "SqlServer"));
IConfiguration sqliteConfig = BuildConfig();
Assert.True(
SecretsRegistration.UsesCentralSharedStore(sqlServerConfig, SecretsNodeRole.Central));
Assert.False(
SecretsRegistration.UsesCentralSharedStore(sqlServerConfig, SecretsNodeRole.Site));
Assert.False(
SecretsRegistration.UsesCentralSharedStore(sqliteConfig, SecretsNodeRole.Central));
}
/// <summary>Verifies that the central pre-host expander resolves the shared SQL store when the provider is SqlServer.</summary>
[Fact]
public void ExpanderCentral_StoreProviderSqlServer_ResolvesSharedSqlStore()
{
// The bug this guards against: the expander used to be wired ONLY to the retired Grpc
// mode, so a Central node on Store:Provider=SqlServer (the retirement message's own
// prescribed replacement) resolved boot-time ${secret:} tokens from the wrong store.
IConfiguration config = BuildConfig(
("Secrets:Store:Provider", "SqlServer"),
("Secrets:SqlServer:ConnectionString", DummyConnectionString));
var services = new ServiceCollection();
services.AddLogging();
services.AddPreHostExpander(config, SecretsNodeRole.Central, NewContentRoot());
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
Assert.IsType<SqlServerSecretStore>(provider.GetRequiredService<ISecretStore>());
// The store-agnostic migrator seam — resolving through it is what lets Program.cs's
// expander block stay store-agnostic instead of re-deriving which concrete migrator to
// ask for.
Assert.NotNull(provider.GetRequiredService<ISecretsStoreMigrator>());
}
/// <summary>Verifies that the central pre-host expander resolves the SQLite store by default.</summary>
[Fact]
public void ExpanderCentral_Default_ResolvesSqliteStore()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddPreHostExpander(BuildConfig(), SecretsNodeRole.Central, NewContentRoot());
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
Assert.NotNull(provider.GetRequiredService<ISecretsStoreMigrator>());
}
/// <summary>Verifies that the site pre-host expander still resolves the SQLite store when the provider is SqlServer.</summary>
[Fact]
public void ExpanderSite_StoreProviderSqlServer_StillResolvesSqliteStore()
{
// A Site ignores Store:Provider entirely, exactly as the runtime registration does — the
// expander must not be a second place where that rule could be got wrong.
IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer"));
var services = new ServiceCollection();
services.AddLogging();
services.AddPreHostExpander(config, SecretsNodeRole.Site, NewContentRoot());
Assert.DoesNotContain(services, IsFromSqlServerPackage);
using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
}
/// <summary>Verifies that the central pre-host expander throws when the provider is SqlServer and the connection string is blank.</summary>
[Fact]
public void ExpanderCentral_StoreProviderSqlServer_BlankConnectionString_Throws()
{
var services = new ServiceCollection();
services.AddLogging();
IConfiguration config = BuildConfig(("Secrets:Store:Provider", "SqlServer"));
var ex = Assert.Throws<InvalidOperationException>(
() => services.AddPreHostExpander(config, SecretsNodeRole.Central, NewContentRoot()));
Assert.Contains(
SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
Assert.Contains(
$"{SecretsRegistration.StoreProviderKey}=SqlServer", ex.Message, StringComparison.Ordinal);
}
/// <summary>Verifies that the central pre-host expander throws when the SqlServer connection string is itself a secret reference.</summary>
[Fact]
public void ExpanderCentral_StoreProviderSqlServer_SecretReferenceConnectionString_Throws()
{
// The bootstrap-circularity rule applies here too: this expander is exactly the code that
// would otherwise have to resolve the reference in order to build the store that resolves
// it.
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.AddPreHostExpander(config, SecretsNodeRole.Central, NewContentRoot()));
Assert.Contains(
SecretsRegistration.HubConnectionStringKey, ex.Message, StringComparison.Ordinal);
Assert.Contains("${secret:", ex.Message, StringComparison.Ordinal);
Assert.DoesNotContain("hub-connection-string", ex.Message, StringComparison.Ordinal);
}
}