using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using MQTTnet; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests; /// /// Live validation of the plain-MQTT (P1) driver against the real Mosquitto fixture in /// Docker/docker-compose.yml — auth + TLS on :8883, authenticated plaintext on /// :1883, and a publisher emitting retained JSON. Every component exercised here is the /// production one: (broker session, TLS, CA pin), /// (SUBSCRIBE, routing, extraction) and /// (the ISubscribable/IReadable surface). /// /// Env-gated + skip-clean. Without MQTT_FIXTURE_ENDPOINT (and the credentials) /// every test calls Assert.Skip, so dotnet test is green on a laptop with no /// Docker. See for the full env-var list. /// /// /// Why these four things. The TLS/auth legs are the first time the Task-3 handshake /// code meets a real broker — until now the CA-pin path had only ever seen synthetic certs, /// and its failure mode (a pin that accepts everything) is invisible without a negative /// control, which is why exists /// alongside the positive. The subscribe leg asserts the published RawPath, the single /// most defect-prone identity in this plan. And the unauthored-topic leg is written so it /// cannot pass vacuously — it proves an authored tag was flowing throughout the window in /// which the unauthored topic produced nothing. /// /// [Collection(MqttFixtureCollection.Name)] [Trait("Category", "LiveIntegration")] public sealed class PlainMqttLiveTests(MqttFixture fixture) { /// OPC UA BadWaitingForInitialData — "no value has ever been observed". private const uint BadWaitingForInitialData = 0x80320000u; /// OPC UA BadNodeIdUnknown — "that reference is not an authored MQTT tag". private const uint BadNodeIdUnknown = 0x80340000u; /// OPC UA Good. private const uint Good = 0x00000000u; /// /// Bound on every wait for broker traffic. The fixture publisher emits every 2s by default, /// so 30s is ~15 publish cycles — generous enough to absorb a slow container start, short /// enough that a genuinely dark subscription fails rather than hangs (the plan's per-op /// bounded-deadline rule). /// private static readonly TimeSpan MessageTimeout = TimeSpan.FromSeconds(30); private readonly MqttFixture _fx = fixture; // ----------------------------------------------------------------------- // TLS + auth // ----------------------------------------------------------------------- /// /// Connects over the TLS listener with real credentials and asserts the session establishes. /// Pins to the fixture CA when MQTT_FIXTURE_CA_CERT is supplied — so on a fully /// provisioned rig this is simultaneously the CA-pin happy path — and falls back to /// AllowUntrustedServerCertificate otherwise so the auth leg still runs. /// [Fact] public async Task Tls_auth_connect_succeeds() { SkipIfUnconfigured(); var ct = TestContext.Current.CancellationToken; await using var connection = new MqttConnection(TlsOptions(), "mqtt-live-tls"); await connection.ConnectAsync(ct); connection.IsConnected.ShouldBeTrue("a TLS+auth connect against the fixture broker must establish a session"); connection.State.ShouldBe(MqttConnectionState.Connected); TestContext.Current.SendDiagnosticMessage( $"TLS+auth connect to {_fx.TlsHost}:{_fx.TlsPort} succeeded " + $"(ca pin: {_fx.CaCertificatePath ?? ""})."); } /// /// The auth negative, and the assertion that gives every other test in this file its meaning: /// a wrong password must never yield a usable session. The broker runs /// allow_anonymous false, so if bad credentials produced a session the fixture would be /// misconfigured and "connected" would prove nothing about auth. /// /// LIVE-GATE FINDING (2026-07-24), now FIXED — this test was tightened accordingly. /// Against real Mosquitto, originally /// returned normally for a CONNECT the broker rejected as not-authorized: MQTTnet 5 /// does not throw on an unsuccessful CONNACK, and ConnectCoreAsync then /// unconditionally published and started the /// reconnect supervisor. Measured at +300 ms with a wrong password: /// thrown=<none>, IsConnected=False, State=Reconnecting. Broker /// side: Client … disconnected, not authorised. The operator-visible consequence was /// that InitializeAsync reported DriverState.Healthy / HostState.Running /// off a connect that never authenticated, so a deployment carrying the wrong broker /// password sealed green. /// /// /// ConnectCoreAsync now inspects the CONNACK and raises /// , so this asserts the strong form: the /// rejection is surfaced, classified as unrecoverable, and lands the connection in /// . The original weak invariant — polled over 5 s /// so the optimistic Connected window cannot pass it for the wrong reason — is kept /// underneath it, because "no session is ever established" is the property that actually /// matters to a consumer and it must hold regardless of how the failure is reported. /// /// [Fact] public async Task Tls_connect_with_wrong_password_is_rejected_and_surfaced() { SkipIfUnconfigured(); var ct = TestContext.Current.CancellationToken; var options = TlsOptions() with { Password = _fx.Password + "-definitely-wrong" }; await using var connection = new MqttConnection(options, "mqtt-live-badpass"); var rejection = await Should.ThrowAsync( async () => await connection.ConnectAsync(ct)); // Mosquitto answers bad credentials with NotAuthorized (MQTT 5) / 0x05 (3.1.1); either maps to // the same unrecoverable classification, which is what stops the reconnect supervisor. rejection.IsUnrecoverable.ShouldBeTrue( $"a broker that refused the credentials returned {rejection.ResultCode}, classified as retryable"); rejection.Message.ShouldNotContain(options.Password ?? "", Case.Sensitive); connection.State.ShouldBe(MqttConnectionState.Faulted); // Poll rather than sample once: a regression that went back to publishing an optimistic // Connected would still read "not connected" in the first millisecond. Every sample must show // no session — a correct password would make IsConnected true within the first sample. var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); while (DateTime.UtcNow < deadline) { connection.IsConnected.ShouldBeFalse( "a CONNECT the broker rejected as not-authorized must never yield a usable session"); connection.State.ShouldNotBe( MqttConnectionState.Connected, "a rejected CONNECT must never advertise a healthy session"); await Task.Delay(100, ct); } TestContext.Current.SendDiagnosticMessage( $"wrong-password connect: {rejection.ResultCode} (unrecoverable={rejection.IsUnrecoverable}), " + $"IsConnected={connection.IsConnected}, State={connection.State}."); } /// /// CA-pin happy path: CaCertificatePath set to the fixture's own CA, with the /// untrusted-certificate escape hatch explicitly OFF, so the handshake can only succeed by /// chaining the broker's leaf up to the pinned root through /// MqttConnection.ValidateAgainstPinnedCa. /// [Fact] public async Task Tls_connect_pinned_to_fixture_ca_succeeds() { SkipIfUnconfigured(); if (_fx.CaCertificatePath is null) { Assert.Skip( $"Skipped: set {MqttFixture.CaCertEnvVar} to the fixture CA (Docker/secrets/ca.crt) to run the CA-pin leg."); } File.Exists(_fx.CaCertificatePath).ShouldBeTrue( $"{MqttFixture.CaCertEnvVar}='{_fx.CaCertificatePath}' must point at the fixture CA PEM"); var ct = TestContext.Current.CancellationToken; var options = TlsOptions() with { CaCertificatePath = _fx.CaCertificatePath, AllowUntrustedServerCertificate = false, }; await using var connection = new MqttConnection(options, "mqtt-live-capin"); await connection.ConnectAsync(ct); connection.IsConnected.ShouldBeTrue("the broker leaf must chain up to the pinned fixture CA"); } /// /// The pin's falsifiability control, and the reason the positive above means anything: pin to /// a CA the broker's certificate was not issued by and assert the handshake fails. A /// validator that returned true unconditionally — the silent failure mode for this /// kind of code — would pass the positive test and fail here. /// /// The foreign CA is generated in-process rather than taken from the fixture, so this leg /// needs no extra rig provisioning and cannot accidentally be handed the real CA. /// /// [Fact] public async Task Tls_connect_pinned_to_a_foreign_ca_is_rejected() { SkipIfUnconfigured(); var ct = TestContext.Current.CancellationToken; var foreignCaPath = Path.Combine(Path.GetTempPath(), $"otopcua-mqtt-foreign-ca-{Guid.NewGuid():N}.pem"); await File.WriteAllTextAsync(foreignCaPath, CreateForeignCaPem(), ct); try { var options = TlsOptions() with { CaCertificatePath = foreignCaPath, AllowUntrustedServerCertificate = false, // The handshake fails fast; no need to sit on the default 15s budget. ConnectTimeoutSeconds = 10, }; await using var connection = new MqttConnection(options, "mqtt-live-foreignca"); await Should.ThrowAsync(async () => await connection.ConnectAsync(ct)); connection.IsConnected.ShouldBeFalse( "a broker certificate that does not chain to the pinned CA must be refused — fail closed"); } finally { File.Delete(foreignCaPath); } } // ----------------------------------------------------------------------- // Plain subscribe / read // ----------------------------------------------------------------------- /// /// The identity assertion. Subscribes an authored tag whose RawPath is deliberately unrelated /// to its MQTT topic, and asserts the driver publishes the change under the RawPath — /// the v3 driver wire reference DriverHostActor fans out to the raw + UNS NodeIds. A /// driver that keyed notifications by topic (or by the TagConfig blob, the retired pre-v3 /// shape) would deliver a value that no node in the address space is listening for, and every /// "did a value arrive" assertion would still pass. /// [Fact] public async Task Plain_subscribe_delivers_value_under_rawpath() { SkipIfUnconfigured(); var ct = TestContext.Current.CancellationToken; const string rawPath = "Fixture/Mqtt/Line1/Temperature"; var tag = JsonTag(rawPath, _fx.Topic("line1/temperature"), "$.value", DriverDataType.Float64); await using var driver = new MqttDriver(PlainOptions(tag), "mqtt-live-subscribe"); var recorder = new DataChangeRecorder(driver); await driver.InitializeAsync(string.Empty, ct); await driver.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct); var change = await recorder.WaitForAsync(rawPath, MessageTimeout, ct); change.FullReference.ShouldBe( rawPath, "the driver must publish under the tag's RawPath, never its topic or its TagConfig blob"); change.Snapshot.StatusCode.ShouldBe(Good); change.Snapshot.Value.ShouldBeOfType().ShouldBeInRange(-100d, 1000d); // ...and the read path serves the same last value under the same key. var read = await driver.ReadAsync([rawPath], ct); read.Count.ShouldBe(1); read[0].StatusCode.ShouldBe(Good); read[0].Value.ShouldNotBeNull(); } /// /// Retained-message seeding. The fixture publisher emits retained/seed exactly once, /// at container start, and never republishes it — so a subscriber connecting later can only /// obtain a value from the broker's retained copy. Asserting the value on a topic that is /// also republished every couple of seconds would prove nothing: a live publish /// arriving quickly looks identical to a retained seed. /// [Fact] public async Task Retained_message_seeds_value_on_subscribe() { SkipIfUnconfigured(); var ct = TestContext.Current.CancellationToken; const string rawPath = "Fixture/Mqtt/Retained/Seed"; var tag = JsonTag(rawPath, _fx.Topic("retained/seed"), "$.value", DriverDataType.Float64); await using var driver = new MqttDriver(PlainOptions(tag), "mqtt-live-retained"); var recorder = new DataChangeRecorder(driver); await driver.InitializeAsync(string.Empty, ct); await driver.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct); var change = await recorder.WaitForAsync(rawPath, MessageTimeout, ct); change.FullReference.ShouldBe(rawPath); change.Snapshot.StatusCode.ShouldBe(Good); change.Snapshot.Value.ShouldBeOfType().ShouldBe( 42.5d, "the retained seed carries the publisher's one-shot value; anything else means the " + "subscription was seeded from somewhere other than the broker's retained store"); } /// /// A chatty broker must not auto-provision. Publishes on a topic no tag was authored for and /// asserts the driver never raises a notification for it. /// /// Deliberately falsifiable. A version of this test that authored nothing would /// pass vacuously — silence proves nothing if the pipeline is dead. So a real tag is /// authored and subscribed first, and the test asserts that authored tag kept delivering /// throughout the window in which the unauthored topic was being published. The /// only way both assertions hold is if the driver was live and chose to ignore the /// unauthored traffic. /// /// [Fact] public async Task Unauthored_topic_is_silent_while_an_authored_tag_keeps_flowing() { SkipIfUnconfigured(); var ct = TestContext.Current.CancellationToken; const string authoredRawPath = "Fixture/Mqtt/Line1/Counter"; const string unauthoredRawPath = "Fixture/Mqtt/Line1/NotAuthored"; // Unique per run so a previous run's retained message can never seed it. var unauthoredTopic = _fx.Topic($"unauthored/{Guid.NewGuid():N}"); var tag = JsonTag(authoredRawPath, _fx.Topic("line1/counter"), "$.value", DriverDataType.Int64); await using var driver = new MqttDriver(PlainOptions(tag), "mqtt-live-unauthored"); var recorder = new DataChangeRecorder(driver); await driver.InitializeAsync(string.Empty, ct); // Subscribing an unauthored reference alongside the authored one: the driver must say // BadNodeIdUnknown rather than leaving it forever "waiting for initial data" — and must // never later turn it Good off broker traffic. await driver.SubscribeAsync([authoredRawPath, unauthoredRawPath], TimeSpan.FromSeconds(1), ct); // Proof the pipeline is live BEFORE the unauthored traffic starts. await recorder.WaitForAsync(authoredRawPath, MessageTimeout, ct); var beforeCount = recorder.CountFor(authoredRawPath); // Emit unauthored traffic with our own client — the fixture publisher's own noise topic is // not enough, because a test must be able to point at the exact messages it caused. await PublishUnauthoredAsync(unauthoredTopic, messages: 5, ct); // Let the authored tag tick at least once more while the unauthored messages sit delivered. await recorder.WaitForCountAsync(authoredRawPath, beforeCount + 1, MessageTimeout, ct); var seen = recorder.Snapshot(); seen.ShouldAllBe( e => e.FullReference == authoredRawPath, "the driver must raise notifications ONLY for authored tags; a broker's other topics are none of its business. " + $"Saw: {string.Join(", ", seen.Select(e => e.FullReference).Distinct())}"); var read = await driver.ReadAsync([authoredRawPath, unauthoredRawPath], ct); read[0].StatusCode.ShouldBe(Good, "the authored tag was flowing throughout — this is the falsifiability control"); read[1].StatusCode.ShouldBeOneOf( BadNodeIdUnknown, BadWaitingForInitialData); read[1].Value.ShouldBeNull("an unauthored reference must never acquire a value from broker traffic"); } // ----------------------------------------------------------------------- // helpers // ----------------------------------------------------------------------- private void SkipIfUnconfigured() { if (_fx.NotConfigured) { Assert.Skip(_fx.SkipReason!); } } /// Driver options for the TLS listener, pinning the fixture CA when one was supplied. private MqttDriverOptions TlsOptions(params RawTagEntry[] rawTags) => new() { Host = _fx.TlsHost, Port = _fx.TlsPort, UseTls = true, // With no CA to pin, the fixture's self-issued chain cannot validate against the OS trust // store, so the documented dev escape hatch is the only way the auth leg can run at all. CaCertificatePath = _fx.CaCertificatePath, AllowUntrustedServerCertificate = _fx.CaCertificatePath is null, Username = _fx.Username, Password = _fx.Password, ClientId = $"otopcua-live-{Guid.NewGuid():N}", ConnectTimeoutSeconds = 15, Mode = MqttMode.Plain, Plain = new MqttPlainOptions { DefaultQos = 1 }, RawTags = rawTags, }; /// Driver options for the plaintext listener — still authenticated. private MqttDriverOptions PlainOptions(params RawTagEntry[] rawTags) => new() { Host = _fx.PlainHost, Port = _fx.PlainPort, UseTls = false, Username = _fx.Username, Password = _fx.Password, ClientId = $"otopcua-live-{Guid.NewGuid():N}", ConnectTimeoutSeconds = 15, Mode = MqttMode.Plain, Plain = new MqttPlainOptions { DefaultQos = 1 }, RawTags = rawTags, }; private static RawTagEntry JsonTag(string rawPath, string topic, string jsonPath, DriverDataType dataType) => new( rawPath, $$""" {"topic":"{{topic}}","payloadFormat":"Json","jsonPath":"{{jsonPath}}","dataType":"{{dataType}}","qos":1,"retainSeed":true} """, WriteIdempotent: false); /// /// Publishes on a topic no tag is authored for, with a raw MQTTnet client rather than through /// the driver (the driver has no write path in P1, and the point is traffic the driver did /// not originate). /// private async Task PublishUnauthoredAsync(string topic, int messages, CancellationToken ct) { using var client = new MqttClientFactory().CreateMqttClient(); var options = new MqttClientOptionsBuilder() .WithTcpServer(_fx.PlainHost, _fx.PlainPort) .WithCredentials(_fx.Username, _fx.Password) .WithClientId($"otopcua-live-noise-{Guid.NewGuid():N}") .WithTimeout(TimeSpan.FromSeconds(10)) .Build(); await client.ConnectAsync(options, ct); try { for (var i = 0; i < messages; i++) { await client.PublishStringAsync( topic, $$"""{"value":{{i}},"source":"unauthored-live-test"}""", MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce, retain: false, ct); } } finally { await client.DisconnectAsync(cancellationToken: ct); } } /// /// A self-signed CA the fixture broker's certificate was definitively NOT issued by, as PEM. /// Generated per call so nothing about it can be shared with — or mistaken for — the real /// fixture CA. /// private static string CreateForeignCaPem() { using var key = RSA.Create(2048); var request = new CertificateRequest( "CN=OtOpcUa MQTT foreign CA (negative control)", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); request.CertificateExtensions.Add( new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); using var certificate = request.CreateSelfSigned( DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); var builder = new StringBuilder(); builder.AppendLine("-----BEGIN CERTIFICATE-----"); builder.AppendLine(Convert.ToBase64String(certificate.RawData, Base64FormattingOptions.InsertLineBreaks)); builder.AppendLine("-----END CERTIFICATE-----"); return builder.ToString(); } /// /// Records every OnDataChange the driver raises and lets a test await a specific /// RawPath. Attached in the constructor — before the MQTT subscribe — because the /// retained seed can arrive inside the SUBSCRIBE round-trip, and a handler attached /// afterwards would miss exactly the message the retained-seed test is about. /// private sealed class DataChangeRecorder { private readonly List _events = []; private readonly Lock _gate = new(); private readonly List _waiters = []; public DataChangeRecorder(MqttDriver driver) => driver.OnDataChange += OnDataChange; /// A point-in-time copy of everything recorded so far. /// The recorded events. public IReadOnlyList Snapshot() { lock (_gate) { return [.. _events]; } } /// How many notifications have been recorded for one RawPath. /// The RawPath to count. /// The count. public int CountFor(string rawPath) { lock (_gate) { return _events.Count(e => e.FullReference == rawPath); } } /// Awaits the first notification for . /// The RawPath to await. /// Bound on the wait. /// Caller cancellation. /// The first matching notification. public Task WaitForAsync( string rawPath, TimeSpan timeout, CancellationToken cancellationToken) => WaitForCountAsync(rawPath, count: 1, timeout, cancellationToken); /// /// Awaits the -th notification for . /// Pre-scans what has already been recorded, so a caller that registers the wait after /// the message arrived is served immediately rather than timing out. /// /// The RawPath to await. /// How many notifications must have been seen. /// Bound on the wait. /// Caller cancellation. /// The -th matching notification. public async Task WaitForCountAsync( string rawPath, int count, TimeSpan timeout, CancellationToken cancellationToken) { Waiter waiter; lock (_gate) { var matches = _events.Where(e => e.FullReference == rawPath).ToList(); if (matches.Count >= count) { return matches[count - 1]; } waiter = new Waiter(rawPath, count); _waiters.Add(waiter); } try { return await waiter.Completion.Task.WaitAsync(timeout, cancellationToken); } catch (TimeoutException) { throw new TimeoutException( $"No notification #{count} for RawPath '{rawPath}' within {timeout.TotalSeconds:0}s. " + $"Recorded so far: {DescribeRecorded()}. Is the fixture publisher container running " + "(`docker logs otopcua-mqtt-publisher`), and does MQTT_FIXTURE_TOPIC_PREFIX match it?"); } finally { lock (_gate) { _waiters.Remove(waiter); } } } private string DescribeRecorded() { var snapshot = Snapshot(); return snapshot.Count == 0 ? "(nothing)" : string.Join(", ", snapshot.GroupBy(e => e.FullReference).Select(g => $"{g.Key}×{g.Count()}")); } private void OnDataChange(object? sender, DataChangeEventArgs args) { List? satisfied = null; lock (_gate) { _events.Add(args); foreach (var waiter in _waiters) { if (waiter.RawPath != args.FullReference) continue; if (_events.Count(e => e.FullReference == waiter.RawPath) < waiter.Count) continue; (satisfied ??= []).Add(waiter); } } // Completed OUTSIDE the lock, and with RunContinuationsAsynchronously below, so a test // continuation never runs on MQTTnet's dispatcher thread while holding this lock — // that would stall delivery for every other subscription. if (satisfied is null) return; foreach (var waiter in satisfied) { waiter.Completion.TrySetResult(args); } } private sealed record Waiter(string RawPath, int Count) { public TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); } } }