dd0a846b64
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
97 lines
3.2 KiB
C#
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);
|
|
}
|
|
}
|