using System.Text.Json; using System.Text.Json.Nodes; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; /// /// Round-trip, enum-serialization and validation guards for the MQTT driver config form model. /// /// The load-bearing assertion is : /// it deserializes this form's output through — the exact instance /// MqttDriverFactoryExtensions.CreateInstance and MqttDriverProbe use — so an /// AdminUI-authored blob that Test-connect accepts cannot fault the deployed driver. That is this /// repo's documented systemic driver-enum-serialization bug, reproduced as a guard. /// /// public sealed class MqttDriverFormModelTests { private static JsonObject Parse(string json) => (JsonObject)JsonNode.Parse(json)!; private static bool HasKeyIgnoringCase(JsonObject o, string name) => o.Any(p => string.Equals(p.Key, name, StringComparison.OrdinalIgnoreCase)); // --- defaults ------------------------------------------------------------------------------- [Fact] public void Defaults_match_the_driver_options_secure_posture() { var model = MqttDriverFormModel.FromJson(null); var driverDefaults = new MqttDriverOptions(); model.Port.ShouldBe(driverDefaults.Port); model.UseTls.ShouldBeTrue(); model.UseTls.ShouldBe(driverDefaults.UseTls); model.AllowUntrustedServerCertificate.ShouldBeFalse(); model.AllowUntrustedServerCertificate.ShouldBe(driverDefaults.AllowUntrustedServerCertificate); model.ProtocolVersion.ShouldBe(driverDefaults.ProtocolVersion); model.Mode.ShouldBe(driverDefaults.Mode); model.MaxPayloadBytes.ShouldBe(driverDefaults.MaxPayloadBytes); // The redundancy-pair eviction guard: an unauthored driver leaves clientId unset so MQTTnet // generates a unique id per connection (P1 live-gate finding). model.ClientId.ShouldBe(""); } // --- round-trip ----------------------------------------------------------------------------- [Fact] public void Round_trip_preserves_every_authored_field() { var model = MqttDriverFormModel.FromJson(null); model.Host = "broker.internal"; model.Port = 1883; model.ClientId = "otopcua-site-a"; model.Username = "otopcua"; model.Password = "s3cret"; model.UseTls = false; model.AllowUntrustedServerCertificate = true; model.CaCertificatePath = "/etc/ssl/ca.pem"; model.ProtocolVersion = MqttProtocolVersion.V311; model.CleanSession = false; model.KeepAliveSeconds = 45; model.ConnectTimeoutSeconds = 9; model.ReconnectMinBackoffSeconds = 2; model.ReconnectMaxBackoffSeconds = 60; model.Mode = MqttMode.Plain; model.MaxPayloadBytes = 4096; model.TopicPrefix = "otopcua/fixture/"; model.DefaultQos = 2; var back = MqttDriverFormModel.FromJson(model.ToJson()); back.Host.ShouldBe("broker.internal"); back.Port.ShouldBe(1883); back.ClientId.ShouldBe("otopcua-site-a"); back.Username.ShouldBe("otopcua"); back.Password.ShouldBe("s3cret"); back.UseTls.ShouldBeFalse(); back.AllowUntrustedServerCertificate.ShouldBeTrue(); back.CaCertificatePath.ShouldBe("/etc/ssl/ca.pem"); back.ProtocolVersion.ShouldBe(MqttProtocolVersion.V311); back.CleanSession.ShouldBeFalse(); back.KeepAliveSeconds.ShouldBe(45); back.ConnectTimeoutSeconds.ShouldBe(9); back.ReconnectMinBackoffSeconds.ShouldBe(2); back.ReconnectMaxBackoffSeconds.ShouldBe(60); back.Mode.ShouldBe(MqttMode.Plain); back.MaxPayloadBytes.ShouldBe(4096); back.TopicPrefix.ShouldBe("otopcua/fixture/"); back.DefaultQos.ShouldBe(2); } // --- the factory-parity guard (the point of the whole file) --------------------------------- [Fact] public void Serialized_blob_binds_onto_MqttDriverOptions_through_the_shared_MqttJson_options() { var model = MqttDriverFormModel.FromJson(null); model.Host = "10.100.0.35"; model.Port = 8883; model.Username = "otopcua"; model.Password = "pw"; model.ProtocolVersion = MqttProtocolVersion.V311; model.Mode = MqttMode.SparkplugB; model.ConnectTimeoutSeconds = 7; model.DefaultQos = 0; // The exact instance the runtime factory + probe + driver + browser parse through. var options = JsonSerializer.Deserialize(model.ToJson(), MqttJson.Options); options.ShouldNotBeNull(); options.Host.ShouldBe("10.100.0.35"); options.Port.ShouldBe(8883); options.Username.ShouldBe("otopcua"); options.Password.ShouldBe("pw"); options.ProtocolVersion.ShouldBe(MqttProtocolVersion.V311); options.Mode.ShouldBe(MqttMode.SparkplugB); options.ConnectTimeoutSeconds.ShouldBe(7); options.Plain.ShouldNotBeNull(); options.Plain.DefaultQos.ShouldBe(0); } [Fact] public void Enums_serialize_as_names_never_as_ordinals() { var model = MqttDriverFormModel.FromJson(null); model.Mode = MqttMode.SparkplugB; model.ProtocolVersion = MqttProtocolVersion.V311; var json = model.ToJson(); json.ShouldContain("\"Mode\":\"SparkplugB\""); json.ShouldContain("\"ProtocolVersion\":\"V311\""); // The historical bug: a numerically-serialized enum that the string-typed runtime binder faults on. json.ShouldNotContain("\"Mode\":1", Case.Sensitive); json.ShouldNotContain("\"ProtocolVersion\":0", Case.Sensitive); } [Fact] public void An_ordinal_only_reader_cannot_bind_the_blob_which_proves_the_enums_are_names() { // The string-match-free half of the enum pin, and a falsifiability control for the test above. // A JsonSerializerOptions WITHOUT a JsonStringEnumConverter can read a NUMERIC enum but not a // named one — so if this form ever regressed to writing ordinals (the historical driver bug), // this deserialize would start SUCCEEDING and the test would fail. Deliberately independent of // MqttJson.Options, because a round-trip through one shared instance is symmetric and would // happily pass on ordinals at both ends. var model = MqttDriverFormModel.FromJson(null); model.Mode = MqttMode.SparkplugB; model.ProtocolVersion = MqttProtocolVersion.V311; var ordinalOnlyReader = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; Should.Throw( () => JsonSerializer.Deserialize(model.ToJson(), ordinalOnlyReader)); } // --- blob hygiene --------------------------------------------------------------------------- [Fact] public void RawTags_are_never_written_into_the_driver_config() { // The deploy artifact injects RawTags via DriverDeviceConfigMerger; a form-authored copy would // be dead weight at best and a stale shadow at worst. const string inbound = """{"Host":"h","rawTags":[{"RawPath":"a/b","TagConfig":"{}"}]}"""; var json = MqttDriverFormModel.FromJson(inbound).ToJson(); HasKeyIgnoringCase(Parse(json), "RawTags").ShouldBeFalse(); } [Fact] public void Unknown_keys_and_the_sparkplug_subobject_survive_a_load_save() { const string inbound = """ {"Host":"h","sparkplug":{"GroupId":"G1","HostId":"H1","ActAsPrimaryHost":true},"aFutureKey":42} """; var json = MqttDriverFormModel.FromJson(inbound).ToJson(); var o = Parse(json); // The blob has no Mode key, so it is Plain — and in Plain mode this form does not author the // Sparkplug sub-object at all, so it must come back byte-identical. o["sparkplug"].ShouldNotBeNull(); o["sparkplug"]!["GroupId"]!.GetValue().ShouldBe("G1"); o["sparkplug"]!["ActAsPrimaryHost"]!.GetValue().ShouldBeTrue(); o["aFutureKey"]!.GetValue().ShouldBe(42); } // --- Sparkplug B authoring ------------------------------------------------------------------- // // These pin the defect the P2 live gate found: MqttDriverForm shipped its Sparkplug branch as a // "not implemented yet" placeholder, so once Sparkplug ingest landed the group id — the driver's // ENTIRE subscription filter, spBv1.0/{GroupId}/# — could not be authored from the AdminUI at all. // The driver deployed connected, Healthy, and ingesting nothing. [Fact] public void Sparkplug_mode_round_trips_the_group_id_through_a_load_save_load() { var authored = MqttDriverFormModel.FromJson("""{"Host":"h"}"""); authored.Mode = MqttMode.SparkplugB; authored.SparkplugGroupId = "OtOpcUaSim"; authored.SparkplugRequestRebirthOnGap = true; var reloaded = MqttDriverFormModel.FromJson(authored.ToJson()); reloaded.Mode.ShouldBe(MqttMode.SparkplugB); reloaded.SparkplugGroupId.ShouldBe("OtOpcUaSim"); reloaded.SparkplugRequestRebirthOnGap.ShouldBeTrue(); } [Fact] public void Sparkplug_mode_emits_a_group_id_the_driver_options_actually_bind() { // The whole point of the round-trip: what this form writes must deserialize back through the // SAME shared MqttJson.Options the runtime factory uses, or the driver sees a null group. var authored = MqttDriverFormModel.FromJson(null); authored.Mode = MqttMode.SparkplugB; authored.SparkplugGroupId = "OtOpcUaSim"; var options = JsonSerializer.Deserialize(authored.ToJson(), MqttJson.Options)!; options.Mode.ShouldBe(MqttMode.SparkplugB); options.Sparkplug.ShouldNotBeNull(); options.Sparkplug!.GroupId.ShouldBe("OtOpcUaSim"); } [Fact] public void Sparkplug_authoring_merges_over_the_subobject_rather_than_replacing_it() { // Same preserve-what-you-do-not-author discipline the top-level bag applies: a key a newer // driver adds INSIDE the sub-object must survive an older AdminUI's save. const string inbound = """ {"Host":"h","Mode":"SparkplugB","sparkplug":{"GroupId":"Old","aFutureSpbKey":7}} """; var model = MqttDriverFormModel.FromJson(inbound); model.SparkplugGroupId = "New"; var o = Parse(model.ToJson()); o["sparkplug"]!["GroupId"]!.GetValue().ShouldBe("New"); o["sparkplug"]!["aFutureSpbKey"]!.GetValue().ShouldBe(7); } [Fact] public void A_camelCase_sparkplug_key_is_merged_not_duplicated() { const string inbound = """{"Host":"h","Mode":"SparkplugB","sparkplug":{"GroupId":"Old"}}"""; var model = MqttDriverFormModel.FromJson(inbound); model.SparkplugGroupId = "New"; var o = Parse(model.ToJson()); o.Count(p => string.Equals(p.Key, "Sparkplug", StringComparison.OrdinalIgnoreCase)).ShouldBe(1); o["sparkplug"]!["GroupId"]!.GetValue().ShouldBe("New"); } [Fact] public void Plain_mode_never_writes_a_sparkplug_subobject_onto_a_blob_that_had_none() { var model = MqttDriverFormModel.FromJson("""{"Host":"h"}"""); model.SparkplugGroupId = "TypedThenSwitchedAway"; // Mode is still Plain. HasKeyIgnoringCase(Parse(model.ToJson()), "Sparkplug").ShouldBeFalse(); } [Fact] public void A_blank_group_id_is_refused_in_sparkplug_mode_only() { var model = MqttDriverFormModel.FromJson("""{"Host":"h"}"""); // Plain does not care — it binds by topic. model.Validate().ShouldBeNull(); model.Mode = MqttMode.SparkplugB; model.Validate().ShouldNotBeNull(); model.Validate()!.ShouldContain("Sparkplug group ID is required"); } [Theory] [InlineData("Plant/1")] [InlineData("Plant+1")] [InlineData("Plant#1")] public void A_group_id_carrying_a_topic_metacharacter_is_refused(string groupId) { // It is a literal segment inside spBv1.0/{GroupId}/#, so '/' silently widens the filter and // '+'/'#' are not even legal mid-segment. Mirrors MqttTagConfigModel's tag-side rule. var model = MqttDriverFormModel.FromJson("""{"Host":"h"}"""); model.Mode = MqttMode.SparkplugB; model.SparkplugGroupId = groupId; model.Validate()!.ShouldContain("cannot appear in an MQTT topic segment"); } [Fact] public void A_camelCase_inbound_key_is_replaced_not_duplicated() { // MqttJson.Options is PropertyNameCaseInsensitive, so a hand-edited camelCase blob binds; the // form must overwrite that key rather than leave "host" and "Host" fighting in one object. const string inbound = """{"host":"old","port":1111}"""; var model = MqttDriverFormModel.FromJson(inbound); model.Host.ShouldBe("old"); model.Port.ShouldBe(1111); model.Host = "new"; var o = Parse(model.ToJson()); o.Count(p => string.Equals(p.Key, "Host", StringComparison.OrdinalIgnoreCase)).ShouldBe(1); JsonSerializer.Deserialize(o.ToJsonString(), MqttJson.Options)!.Host.ShouldBe("new"); } [Fact] public void Blank_optional_strings_are_omitted_so_the_driver_defaults_apply() { var json = MqttDriverFormModel.FromJson(null).ToJson(); var options = JsonSerializer.Deserialize(json, MqttJson.Options)!; options.ClientId.ShouldBeNull(); options.Username.ShouldBeNull(); options.CaCertificatePath.ShouldBeNull(); } [Fact] public void The_merged_deploy_blob_keeps_the_form_connection_and_gains_the_artifact_RawTags() { // The real deploy seam: DriverDeviceConfigMerger folds this form's DriverConfig together with the // (empty) MQTT DeviceConfig and injects the authored raw tags. MQTT authors its connection on the // DRIVER precisely so this survives 0, 1 or N devices. var model = MqttDriverFormModel.FromJson(null); model.Host = "broker.internal"; model.Port = 1883; model.UseTls = false; model.Mode = MqttMode.Plain; model.DefaultQos = 2; var rawTags = new[] { new RawTagEntry("Plant/Mqtt/Dev1/Temp", """{"topic":"a/b"}""", WriteIdempotent: false, DeviceName: "Dev1"), }; foreach (var devices in new[] { Array.Empty(), [new DriverDeviceConfigMerger.DeviceRow("Dev1", "{}")], [new DriverDeviceConfigMerger.DeviceRow("Dev1", "{}"), new DriverDeviceConfigMerger.DeviceRow("Dev2", "{}")], }) { var merged = DriverDeviceConfigMerger.Merge(model.ToJson(), devices, rawTags); var options = JsonSerializer.Deserialize(merged, MqttJson.Options)!; options.Host.ShouldBe("broker.internal", $"device count {devices.Length}"); options.Port.ShouldBe(1883, $"device count {devices.Length}"); options.UseTls.ShouldBeFalse($"device count {devices.Length}"); options.Plain!.DefaultQos.ShouldBe(2, $"device count {devices.Length}"); options.RawTags.Count.ShouldBe(1, $"device count {devices.Length}"); options.RawTags[0].RawPath.ShouldBe("Plant/Mqtt/Dev1/Temp"); } } // --- validation ----------------------------------------------------------------------------- [Fact] public void Validate_accepts_a_default_model() => MqttDriverFormModel.FromJson(null).Validate().ShouldBeNull(); [Theory] [InlineData("Host", "")] [InlineData("Port", 0)] [InlineData("Port", 70000)] [InlineData("KeepAliveSeconds", 0)] [InlineData("ConnectTimeoutSeconds", 0)] [InlineData("ReconnectMinBackoffSeconds", 0)] [InlineData("ReconnectMaxBackoffSeconds", -1)] [InlineData("MaxPayloadBytes", 0)] [InlineData("DefaultQos", 3)] [InlineData("DefaultQos", -1)] public void Validate_rejects_an_out_of_range_field(string field, object value) { var model = MqttDriverFormModel.FromJson(null); typeof(MqttDriverFormModel).GetProperty(field)!.SetValue(model, value); model.Validate().ShouldNotBeNull(); } [Fact] public void Validate_rejects_a_max_backoff_below_the_min() { var model = MqttDriverFormModel.FromJson(null); model.ReconnectMinBackoffSeconds = 30; model.ReconnectMaxBackoffSeconds = 5; model.Validate().ShouldNotBeNull(); } [Fact] public void An_ignored_validation_error_still_cannot_produce_a_driver_bricking_blob() { // Documented finding: `timeoutSeconds: 0` was an operator-authorable driver-brick. Validate() // reports it; the serializer clamps it, so even a saved-anyway blob stays inside the driver's // own [Range] bounds. var model = MqttDriverFormModel.FromJson(null); model.ConnectTimeoutSeconds = 0; model.KeepAliveSeconds = -5; model.ReconnectMinBackoffSeconds = 0; model.ReconnectMaxBackoffSeconds = -3; model.MaxPayloadBytes = 0; model.Port = 99999; model.DefaultQos = 9; var options = JsonSerializer.Deserialize(model.ToJson(), MqttJson.Options)!; options.ConnectTimeoutSeconds.ShouldBeGreaterThanOrEqualTo(1); options.KeepAliveSeconds.ShouldBeGreaterThanOrEqualTo(1); options.ReconnectMinBackoffSeconds.ShouldBeGreaterThanOrEqualTo(1); options.ReconnectMaxBackoffSeconds.ShouldBeGreaterThanOrEqualTo(1); options.MaxPayloadBytes.ShouldBeGreaterThanOrEqualTo(1); options.Port.ShouldBeInRange(1, 65535); options.Plain!.DefaultQos.ShouldBeInRange(0, 2); } [Fact] public void A_malformed_inbound_blob_degrades_to_defaults_rather_than_throwing() { var model = MqttDriverFormModel.FromJson("}{ not json"); model.Host.ShouldBe(new MqttDriverOptions().Host); model.UseTls.ShouldBeTrue(); } }