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) — the invariant asserted here is deliberately "no
/// session", not "ConnectAsync throws". Against real Mosquitto,
/// returns normally for a CONNECT the
/// broker rejects as not-authorized: MQTTnet 5 does not throw on an unsuccessful CONNACK,
/// and ConnectCoreAsync then unconditionally publishes
/// and starts the reconnect supervisor.
/// Measured at +300 ms with a wrong password: thrown=<none>,
/// IsConnected=False, State=Reconnecting (the broker's socket close had
/// fired DisconnectedAsync by then). Broker side:
/// Client … disconnected, not authorised.
///
///
/// That is a driver-side defect, not a fixture one — InitializeAsync reports
/// DriverState.Healthy / HostState.Running off a connect that never
/// authenticated, so a deployment carrying the wrong broker password seals green and the
/// AdminUI "Test connect" probe is expected to agree. Fixing it belongs in the driver
/// (MqttConnection must inspect the connect result / opt into MQTTnet's
/// throw-on-unsuccessful-CONNACK), which is outside this task's file scope; it is reported
/// as a live-gate finding.
///
///
/// The test is written to hold either way: it records whether the call threw (as a
/// diagnostic, not an assertion) and asserts only the invariant that must be true under
/// both behaviours — no session is ever established. A driver fixed to throw still passes.
///
///
[Fact]
public async Task Tls_connect_with_wrong_password_never_establishes_a_session()
{
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");
Exception? thrown = null;
try
{
await connection.ConnectAsync(ct);
}
catch (Exception ex)
{
thrown = ex;
}
// Poll rather than sample once: the rejection arrives as a broker-initiated socket close a
// few milliseconds after ConnectAsync returns, so a single immediate read could catch the
// optimistic Connected state and pass for the wrong reason. 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");
await Task.Delay(100, ct);
}
connection.State.ShouldNotBe(
MqttConnectionState.Connected,
"the settled state after a rejected CONNECT must not advertise a healthy session");
TestContext.Current.SendDiagnosticMessage(
$"wrong-password connect: threw={thrown?.GetType().Name ?? ""}, "
+ $"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);
}
}
}