using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Validation; namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; /// /// — the shared "a Sql driver never persists a literal connection /// string" check behind both the #498 deploy gate and the #499 node-override save gate. /// /// /// The node-override half is the one #499 is about: ClusterNode.DriverConfigOverridesJson is a /// map keyed by DriverInstanceId that is merged onto the cluster-level DriverConfig, and /// it is invisible to DraftSnapshot — so nothing gated it at all before. /// [Trait("Category", "Unit")] public sealed class SqlCredentialGuardTests { /// The literal an operator would paste into a raw-JSON textarea. private const string Leaked = """{"provider":"SqlServer","connectionString":"Server=sql,1433;Database=Mes;User ID=sa;Password=hunter2"}"""; private const string Clean = """{"provider":"SqlServer","connectionStringRef":"Sql:Line3"}"""; // ---- CarriesLiteralConnectionString -------------------------------------------------------- /// The key is caught at the top level of a config blob. [Fact] public void A_literal_connectionString_is_detected() => SqlCredentialGuard.CarriesLiteralConnectionString(Leaked).ShouldBeTrue(); /// The supported indirect form is not a violation — otherwise the rule would block the fix /// it tells operators to apply. [Fact] public void The_indirect_connectionStringRef_form_is_clean() => SqlCredentialGuard.CarriesLiteralConnectionString(Clean).ShouldBeFalse(); /// System.Text.Json binds ConnectionString to a connectionString property by /// default, so a case variant is the same key — not a bypass. [Theory] [InlineData("""{"ConnectionString":"Server=x;Password=p"}""")] [InlineData("""{"CONNECTIONSTRING":"Server=x;Password=p"}""")] [InlineData("""{"connectionstring":"Server=x;Password=p"}""")] public void Case_variants_are_the_same_key(string json) => SqlCredentialGuard.CarriesLiteralConnectionString(json).ShouldBeTrue(); /// Blank, malformed and non-object blobs simply have no keys. Shaping the config JSON is /// another rule's job, and throwing here would turn a formatting mistake into a credential failure. /// [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("{ not json")] [InlineData("[1,2,3]")] [InlineData("\"a string\"")] public void Blank_malformed_and_non_object_blobs_are_not_violations(string? json) => SqlCredentialGuard.CarriesLiteralConnectionString(json).ShouldBeFalse(); /// Only the top level is scanned. The DTO is flat, so a nested occurrence cannot bind and is /// not the credential-shaped mistake this rule exists to catch. [Fact] public void A_nested_occurrence_is_not_flagged() => SqlCredentialGuard .CarriesLiteralConnectionString("""{"nested":{"connectionString":"Server=x"}}""") .ShouldBeFalse(); // ---- FindNodeOverrideViolations (#499) ----------------------------------------------------- /// The #499 leak: a credential pasted into a node's per-driver override map. [Fact] public void A_credential_in_a_node_override_for_a_Sql_driver_is_a_violation() { var overrides = $$"""{"di-sql": {{Leaked}} }"""; SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]) .ShouldBe(["di-sql"]); } /// Every offending instance is reported, not just the first — an operator fixing one at a /// time would otherwise need as many save attempts as there are leaks. [Fact] public void Every_offending_instance_is_reported() { var overrides = $$"""{"di-a": {{Leaked}}, "di-clean": {{Clean}}, "di-b": {{Leaked}} }"""; SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-a", "di-b", "di-clean"]) .ShouldBe(["di-a", "di-b"]); } /// Keys outside the Sql set are ignored. A non-Sql driver never made the /// indirect-credential guarantee, so flagging it would break a config that is legitimate today — /// a regression, not defence in depth. [Fact] public void An_override_for_a_non_Sql_driver_is_ignored() { var overrides = $$"""{"di-modbus": {{Leaked}} }"""; SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty(); } /// The realistic override — a node-specific endpoint — must keep saving. [Fact] public void A_normal_override_is_clean() { var overrides = """{"di-sql": {"commandTimeoutSeconds": 45}}"""; SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty(); } /// Nothing to check when the cluster has no Sql drivers, or the node has no overrides. [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("{ not json")] [InlineData("[1,2,3]")] public void Blank_and_malformed_override_maps_yield_no_violations(string? overrides) => SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty(); /// An empty Sql-driver set short-circuits — there is no instance the key could belong to. [Fact] public void No_Sql_drivers_means_no_violations() => SqlCredentialGuard.FindNodeOverrideViolations($$"""{"di-sql": {{Leaked}} }""", []) .ShouldBeEmpty(); /// A non-object override entry cannot carry config keys, and must not throw. [Fact] public void A_non_object_override_entry_is_skipped() => SqlCredentialGuard.FindNodeOverrideViolations("""{"di-sql": "connectionString"}""", ["di-sql"]) .ShouldBeEmpty(); }