Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests/SqlServerSecretsOptionsTests.cs
T
Joseph Doherty dd0a846b64 feat(secrets): cluster replication via SQL Server and Akka.NET (G-7, 0.2.0)
Secrets were per-node SQLite, so a secret written on one node was invisible to
the rest of a cluster. G-7's design resolved the "shared SQL store vs Akka
replicator" fork to build only the former; both are built here so the choice is
a deployment decision (availability vs partition tolerance) rather than a
library limitation.

Two new packages — ZB.MOM.WW.Secrets.Replicator.SqlServer (shared store, plus a
local-store-with-hub mode) and .Replicator.AkkaDotNet (peer-to-peer over
distributed pub/sub). Core gains ISecretsStoreMigrator, one shared
SecretLastWriterWins predicate so no two stores can disagree on a tie, the
transport-agnostic reconciler, and ReplicatingSecretStore — which closes a real
gap: nothing had ever called ISecretReplicator.PublishAsync, so the seam was
inert and local writes would not have propagated at all.

Verified 182 pass / 1 skip / 0 warnings, including 15 live tests against a real
SQL Server 2022 (the SQLite suite ported case-for-case, so any behavioural
divergence between the stores fails) and a 9-test in-process 2-node Akka
cluster over real remoting. A post-build review caught six defects, all fixed
and now covered: both replication modes could not resolve from the container
(no test had built one), an unbounded fetch that broke past SQL Server's
2100-parameter cap, a poison row that aborted the rest of its batch forever,
Enum.Parse on peer input that could restart the actor in a loop, null crypto
blobs crossing the trust boundary, and a silently dropped pull-read failure.

Packed at 0.2.0 and vulnerability-scanned clean; not yet published to the feed.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-18 04:08:23 -04:00

97 lines
3.2 KiB
C#

namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests;
/// <summary>
/// The schema-name allow-list is the injection boundary for the one value that cannot be
/// parameterized (a T-SQL object name), so it gets adversarial coverage rather than a happy path.
/// </summary>
public sealed class SqlServerSecretsOptionsTests
{
[Theory]
[InlineData("zbsecrets")]
[InlineData("ZbSecrets")]
[InlineData("_private")]
[InlineData("schema_1")]
public void SchemaName_accepts_plain_identifiers(string value)
{
var options = new SqlServerSecretsOptions { SchemaName = value };
Assert.Equal(value, options.SchemaName);
}
[Theory]
// The bracket-escape trick that would otherwise break out of the [quoting] at the use site.
[InlineData("evil]]; DROP TABLE secret; --")]
[InlineData("dbo.secret")]
[InlineData("with space")]
[InlineData("quote'name")]
[InlineData("semi;colon")]
[InlineData("1leading_digit")]
[InlineData("")]
[InlineData(" ")]
public void SchemaName_rejects_anything_that_is_not_a_plain_identifier(string value)
{
Assert.Throws<ArgumentException>(() => new SqlServerSecretsOptions { SchemaName = value });
}
[Fact]
public void SchemaName_rejects_an_over_long_identifier()
{
Assert.Throws<ArgumentException>(() =>
new SqlServerSecretsOptions { SchemaName = new string('a', 129) });
}
[Fact]
public void Validate_rejects_a_missing_connection_string()
{
InvalidOperationException ex =
Assert.Throws<InvalidOperationException>(() => new SqlServerSecretsOptions().Validate());
Assert.Contains("ConnectionString", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_rejects_a_non_positive_sync_interval()
{
var options = new SqlServerSecretsOptions
{
ConnectionString = "Server=x;",
SyncInterval = TimeSpan.Zero,
};
Assert.Throws<InvalidOperationException>(options.Validate);
}
[Fact]
public void Validate_rejects_a_non_positive_command_timeout()
{
var options = new SqlServerSecretsOptions
{
ConnectionString = "Server=x;",
CommandTimeout = TimeSpan.FromSeconds(-1),
};
Assert.Throws<InvalidOperationException>(options.Validate);
}
[Fact]
public void Validate_accepts_a_fully_configured_instance()
{
var options = new SqlServerSecretsOptions { ConnectionString = "Server=x;Database=y;" };
options.Validate();
}
[Fact]
public void CreateSchemaDdl_quotes_the_schema_name_everywhere_it_appears()
{
string ddl = SqlServerSecretsSchema.CreateSchemaDdl("zbsecrets");
Assert.Contains("[zbsecrets].[secret]", ddl, StringComparison.Ordinal);
Assert.Contains("[zbsecrets].[schema_version]", ddl, StringComparison.Ordinal);
// Every DDL statement is guarded, so re-running the migration on a provisioned database is
// a no-op rather than an error.
Assert.Contains("IF NOT EXISTS", ddl, StringComparison.Ordinal);
Assert.Contains("IS NULL", ddl, StringComparison.Ordinal);
}
}