test(mqtt): Mosquitto TLS+auth fixture + env-gated plain live suite
Adds the MQTT driver integration-test project: an eclipse-mosquitto fixture running auth AND TLS (allow_anonymous false on both listeners), a mosquitto_pub-based JSON publisher emitting retained messages, a cert/password generator script whose output is gitignored, and a live suite gated on MQTT_FIXTURE_ENDPOINT that skips clean offline. The fixture is never anonymous by design: an anonymous broker would let the driver's TLS/CA-pin and auth paths go untested, and those fail silently. The CA-pin leg carries its own falsifiability control (a foreign CA generated in-process must be rejected). Live gate on 10.100.0.35: 7/7 passed. It found a driver defect, reported not fixed here (src/ is out of this task's scope): MqttConnection.ConnectAsync RETURNS NORMALLY when Mosquitto rejects a CONNECT as not-authorized -- MQTTnet 5 does not throw on an unsuccessful CONNACK, so a wrong broker password yields DriverState.Healthy from InitializeAsync. The auth-negative test therefore asserts the invariant that holds either way (no session is ever established) and documents the finding. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Environment gate + reachability probe for the Mosquitto fixture defined in
|
||||
/// <c>Docker/docker-compose.yml</c> (auth + TLS on <c>:8883</c>, authenticated plaintext on
|
||||
/// <c>:1883</c>, plus a JSON publisher emitting retained messages).
|
||||
/// <para>
|
||||
/// <b>Unset env ⇒ skip, never fail.</b> Unlike the Modbus / S7 fixtures — which default to
|
||||
/// the shared Docker host and probe it unconditionally — this one has <b>no default
|
||||
/// endpoint</b>: without <c>MQTT_FIXTURE_ENDPOINT</c> the whole suite reports
|
||||
/// <see cref="NotConfigured"/> and every test calls <c>Assert.Skip</c>. That is deliberate.
|
||||
/// The fixture needs credentials that are generated per machine and never committed, so
|
||||
/// "probe a well-known host and hope" would produce auth failures rather than a clean skip
|
||||
/// on a dev box that never provisioned it. `dotnet test` on macOS with no Docker therefore
|
||||
/// stays green.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Env vars, all read once at fixture construction:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <c>MQTT_FIXTURE_ENDPOINT</c> — <c>host:port</c> of the <b>TLS</b> listener, e.g.
|
||||
/// <c>10.100.0.35:8883</c>. <b>The gate</b>: absent ⇒ the whole suite skips.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <c>MQTT_FIXTURE_PLAIN_ENDPOINT</c> — <c>host:port</c> of the plaintext listener.
|
||||
/// Defaults to the TLS host on port 1883.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <c>MQTT_FIXTURE_USERNAME</c> / <c>MQTT_FIXTURE_PASSWORD</c> — the broker credentials
|
||||
/// <c>gen-fixture-material.sh</c> was run with. The password has no default and is
|
||||
/// never committed; without it the suite skips.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <c>MQTT_FIXTURE_CA_CERT</c> — path to the fixture CA (<c>secrets/ca.crt</c>).
|
||||
/// Optional: supplied, the TLS legs pin to it and the CA-pin test runs; absent, the
|
||||
/// connect legs fall back to <c>AllowUntrustedServerCertificate</c> and the pin test
|
||||
/// skips.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <c>MQTT_FIXTURE_TOPIC_PREFIX</c> — must match the publisher's. Default
|
||||
/// <c>otopcua/fixture</c>.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// The probe is one-shot and its socket is closed immediately: tests open their own real
|
||||
/// <see cref="MqttConnection"/> / <see cref="MqttDriver"/> sessions, and sharing a socket
|
||||
/// would serialise them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MqttFixture
|
||||
{
|
||||
/// <summary>The gate: absent ⇒ the whole suite skips.</summary>
|
||||
public const string EndpointEnvVar = "MQTT_FIXTURE_ENDPOINT";
|
||||
|
||||
/// <summary>Optional override for the plaintext listener; defaults to the TLS host on 1883.</summary>
|
||||
public const string PlainEndpointEnvVar = "MQTT_FIXTURE_PLAIN_ENDPOINT";
|
||||
|
||||
/// <summary>Broker username; defaults to the generator script's own default.</summary>
|
||||
public const string UsernameEnvVar = "MQTT_FIXTURE_USERNAME";
|
||||
|
||||
/// <summary>Broker password. No default — a defaulted fixture password is a committed secret.</summary>
|
||||
public const string PasswordEnvVar = "MQTT_FIXTURE_PASSWORD";
|
||||
|
||||
/// <summary>Path to the fixture CA PEM, for the CA-pin legs.</summary>
|
||||
public const string CaCertEnvVar = "MQTT_FIXTURE_CA_CERT";
|
||||
|
||||
/// <summary>Topic prefix the fixture publisher emits under.</summary>
|
||||
public const string TopicPrefixEnvVar = "MQTT_FIXTURE_TOPIC_PREFIX";
|
||||
|
||||
private const string DefaultUsername = "otopcua";
|
||||
private const string DefaultTopicPrefix = "otopcua/fixture";
|
||||
private const int DefaultPlainPort = 1883;
|
||||
|
||||
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="MqttFixture"/> class.</summary>
|
||||
public MqttFixture()
|
||||
{
|
||||
Username = Environment.GetEnvironmentVariable(UsernameEnvVar) is { Length: > 0 } u ? u : DefaultUsername;
|
||||
Password = Environment.GetEnvironmentVariable(PasswordEnvVar);
|
||||
TopicPrefix = Environment.GetEnvironmentVariable(TopicPrefixEnvVar) is { Length: > 0 } p
|
||||
? p.TrimEnd('/')
|
||||
: DefaultTopicPrefix;
|
||||
|
||||
var caPath = Environment.GetEnvironmentVariable(CaCertEnvVar);
|
||||
CaCertificatePath = string.IsNullOrWhiteSpace(caPath) ? null : caPath;
|
||||
|
||||
var tlsRaw = Environment.GetEnvironmentVariable(EndpointEnvVar);
|
||||
if (string.IsNullOrWhiteSpace(tlsRaw))
|
||||
{
|
||||
SkipReason =
|
||||
$"Skipped: set {EndpointEnvVar} (e.g. 10.100.0.35:8883) plus {UsernameEnvVar}/{PasswordEnvVar} to run "
|
||||
+ "the MQTT fixture live suite. Bring the fixture up first — see "
|
||||
+ "tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml.";
|
||||
return;
|
||||
}
|
||||
|
||||
(TlsHost, TlsPort) = ParseEndpoint(tlsRaw, defaultPort: 8883);
|
||||
|
||||
var plainRaw = Environment.GetEnvironmentVariable(PlainEndpointEnvVar);
|
||||
(PlainHost, PlainPort) = string.IsNullOrWhiteSpace(plainRaw)
|
||||
? (TlsHost, DefaultPlainPort)
|
||||
: ParseEndpoint(plainRaw, DefaultPlainPort);
|
||||
|
||||
if (string.IsNullOrEmpty(Password))
|
||||
{
|
||||
// A blank password is NOT a valid "anonymous" configuration for this fixture — the
|
||||
// broker runs allow_anonymous false — so treat it as unconfigured rather than letting
|
||||
// every test fail with an indistinguishable "not authorized".
|
||||
SkipReason =
|
||||
$"Skipped: {EndpointEnvVar} is set but {PasswordEnvVar} is not. The fixture broker runs "
|
||||
+ "allow_anonymous=false; supply the password gen-fixture-material.sh was run with.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe the TLS listener: it is the endpoint the gate names, and a broker that is up serves
|
||||
// both listeners from one process.
|
||||
SkipReason = Probe(TlsHost, TlsPort);
|
||||
}
|
||||
|
||||
/// <summary>Gets the TLS listener host.</summary>
|
||||
public string TlsHost { get; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the TLS listener port.</summary>
|
||||
public int TlsPort { get; }
|
||||
|
||||
/// <summary>Gets the plaintext listener host.</summary>
|
||||
public string PlainHost { get; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the plaintext listener port.</summary>
|
||||
public int PlainPort { get; }
|
||||
|
||||
/// <summary>Gets the broker username.</summary>
|
||||
public string Username { get; }
|
||||
|
||||
/// <summary>Gets the broker password, or <see langword="null"/> when unconfigured.</summary>
|
||||
public string? Password { get; }
|
||||
|
||||
/// <summary>Gets the fixture CA PEM path, or <see langword="null"/> when not supplied.</summary>
|
||||
public string? CaCertificatePath { get; }
|
||||
|
||||
/// <summary>Gets the topic prefix the fixture publisher emits under (no trailing slash).</summary>
|
||||
public string TopicPrefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reason every test in the suite must skip, or <see langword="null"/> when the
|
||||
/// fixture is configured and reachable.
|
||||
/// </summary>
|
||||
public string? SkipReason { get; }
|
||||
|
||||
/// <summary>Gets a value indicating whether the suite must skip.</summary>
|
||||
public bool NotConfigured => SkipReason is not null;
|
||||
|
||||
/// <summary>Composes a fixture topic under <see cref="TopicPrefix"/>.</summary>
|
||||
/// <param name="suffix">The topic suffix, e.g. <c>line1/temperature</c>.</param>
|
||||
/// <returns>The full topic.</returns>
|
||||
public string Topic(string suffix) => $"{TopicPrefix}/{suffix.TrimStart('/')}";
|
||||
|
||||
private static (string Host, int Port) ParseEndpoint(string raw, int defaultPort)
|
||||
{
|
||||
var parts = raw.Trim().Split(':', 2);
|
||||
var port = parts.Length == 2 && int.TryParse(parts[1], out var parsed) ? parsed : defaultPort;
|
||||
return (parts[0], port);
|
||||
}
|
||||
|
||||
private static string? Probe(string host, int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var connect = client.ConnectAsync(host, port);
|
||||
if (!connect.Wait(ProbeTimeout) || !client.Connected)
|
||||
{
|
||||
return Unreachable(host, port, $"no TCP connection within {ProbeTimeout.TotalSeconds:0}s");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Unreachable(host, port, $"{ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Unreachable(string host, int port, string detail) =>
|
||||
$"Skipped: the MQTT fixture broker at {host}:{port} is configured but unreachable ({detail}). "
|
||||
+ "Bring the stack up on the Docker host: "
|
||||
+ "`ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt && MQTT_FIXTURE_USERNAME=... MQTT_FIXTURE_PASSWORD=... docker compose up -d'`.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition so the reachability probe runs once per test session rather than once
|
||||
/// per test — probing a firewalled endpoint per test would burn the probe timeout each time.
|
||||
/// </summary>
|
||||
[Xunit.CollectionDefinition(Name)]
|
||||
public sealed class MqttFixtureCollection : Xunit.ICollectionFixture<MqttFixture>
|
||||
{
|
||||
/// <summary>The collection name.</summary>
|
||||
public const string Name = "MqttFixture";
|
||||
}
|
||||
Reference in New Issue
Block a user