using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Validation; namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; /// /// The Gitea #498 deploy gate: a Sql driver's persisted config may never carry a literal /// connectionString. The typed DTO already drops the key on read; these pin the /// write side, which is where a credential would actually land in the config database. /// [Trait("Category", "Unit")] public sealed class DraftValidatorSqlSecretTests { private const string Code = "SqlConnectionStringPersisted"; /// A realistic leak: the literal an operator would paste into a raw-JSON config textarea. private const string LeakedConfig = """{"provider":"SqlServer","connectionString":"Server=sql,1433;Database=Mes;User ID=sa;Password=hunter2"}"""; private static DriverInstance SqlDriver(string config) => new() { DriverInstanceId = "di-sql", ClusterId = "c", Name = "line3-sql", DriverType = "Sql", DriverConfig = config, }; private static Device Device(string driverInstanceId, string config) => new() { DeviceId = "dev-1", DriverInstanceId = driverInstanceId, Name = "Device1", DeviceConfig = config, }; private static DraftSnapshot Draft(DriverInstance driver, Device? device = null) => new() { GenerationId = 1, ClusterId = "c", DriverInstances = [driver], Devices = device is null ? [] : [device], }; [Fact] public void Literal_connectionString_in_DriverConfig_is_a_deploy_error() { var errors = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig))); errors.ShouldContain(e => e.Code == Code && e.Context == "di-sql"); } /// /// The error text reaches the AdminUI, the deploy log and the audit trail, so it must describe the /// problem without repeating the credential it is refusing to store. /// [Fact] public void Error_message_does_not_echo_the_credential() { var error = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig))).First(e => e.Code == Code); error.Message.ShouldNotContain("hunter2"); error.Message.ShouldNotContain("Server=sql,1433"); error.Message.ShouldContain("connectionStringRef"); } /// /// System.Text.Json binds ConnectionString to a connectionString property by default, so /// a case variant is the same key — matching it ordinally would leave the obvious bypass wide open. /// [Theory] [InlineData("ConnectionString")] [InlineData("CONNECTIONSTRING")] [InlineData("connectionstring")] public void Key_match_is_case_insensitive(string key) { var config = $$"""{"provider":"SqlServer","{{key}}":"Server=s;Password=p"}"""; DraftValidator.Validate(Draft(SqlDriver(config))) .ShouldContain(e => e.Code == Code && e.Context == "di-sql"); } /// /// DeviceConfig is merged onto DriverConfig before the driver's DTO sees it, so a credential pasted /// there is the identical leak and must fail the same way. /// [Fact] public void Literal_connectionString_in_a_Sql_devices_DeviceConfig_is_a_deploy_error() { var draft = Draft(SqlDriver("""{"connectionStringRef":"DevSql"}"""), Device("di-sql", LeakedConfig)); DraftValidator.Validate(draft).ShouldContain(e => e.Code == Code && e.Context == "dev-1"); } [Fact] public void A_properly_authored_connectionStringRef_passes() { var draft = Draft( SqlDriver("""{"provider":"SqlServer","connectionStringRef":"DevSql","nullIsBad":true}"""), Device("di-sql", """{"pollIntervalMs":1000}""")); DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code); } /// /// The rule is scoped to the Sql driver type. A non-Sql driver is not in its remit — widening the gate /// to every driver is a separate decision, and silently failing an unrelated driver's deploy here would /// be a regression, not defence in depth. /// [Fact] public void A_non_Sql_driver_carrying_the_key_is_not_flagged_by_this_rule() { var modbus = new DriverInstance { DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb", DriverType = "Modbus", DriverConfig = LeakedConfig, }; DraftValidator.Validate(Draft(modbus)).ShouldNotContain(e => e.Code == Code); } /// /// A device under a different driver must not be attributed to the Sql instance — the device /// scan keys off DriverInstanceId, and getting that wrong would flag innocent devices. /// [Fact] public void A_device_under_a_non_Sql_driver_is_not_flagged() { var draft = new DraftSnapshot { GenerationId = 1, ClusterId = "c", DriverInstances = [ SqlDriver("""{"connectionStringRef":"DevSql"}"""), new DriverInstance { DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb", DriverType = "Modbus", DriverConfig = "{}", }, ], Devices = [Device("di-mb", LeakedConfig)], }; DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code); } /// /// Malformed or non-object config must not throw out of the validator: shaping the JSON is another /// rule's job, and a parse failure here would take down every other check in the same pass. /// [Theory] [InlineData("")] [InlineData(" ")] [InlineData("not json at all")] [InlineData("[1,2,3]")] [InlineData("\"connectionString\"")] [InlineData("{\"provider\":\"SqlServer\"")] public void Malformed_config_neither_throws_nor_flags(string config) { Should.NotThrow(() => DraftValidator.Validate(Draft(SqlDriver(config)))) .ShouldNotContain(e => e.Code == Code); } /// /// Only the top level is scanned. The DTO is flat, so a nested occurrence cannot bind to anything and /// is not the credential-shaped mistake this rule exists to catch; flagging it would be a false /// positive on, say, a tag blob that happens to describe a connection string. /// [Fact] public void A_nested_connectionString_is_not_flagged() { var config = """{"connectionStringRef":"DevSql","notes":{"connectionString":"documented elsewhere"}}"""; DraftValidator.Validate(Draft(SqlDriver(config))).ShouldNotContain(e => e.Code == Code); } }