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();
}