using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests;
///
/// Environment gate + reachability probe for the Mosquitto fixture defined in
/// Docker/docker-compose.yml (auth + TLS on :8883, authenticated plaintext on
/// :1883, plus a JSON publisher emitting retained messages).
///
/// Unset env ⇒ skip, never fail. Unlike the Modbus / S7 fixtures — which default to
/// the shared Docker host and probe it unconditionally — this one has no default
/// endpoint: without MQTT_FIXTURE_ENDPOINT the whole suite reports
/// and every test calls Assert.Skip. 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.
///
///
///
///
/// Env vars, all read once at fixture construction:
///
///
/// -
/// MQTT_FIXTURE_ENDPOINT — host:port of the TLS listener, e.g.
/// 10.100.0.35:8883. The gate: absent ⇒ the whole suite skips.
///
/// -
/// MQTT_FIXTURE_PLAIN_ENDPOINT — host:port of the plaintext listener.
/// Defaults to the TLS host on port 1883.
///
/// -
/// MQTT_FIXTURE_USERNAME / MQTT_FIXTURE_PASSWORD — the broker credentials
/// gen-fixture-material.sh was run with. The password has no default and is
/// never committed; without it the suite skips.
///
/// -
/// MQTT_FIXTURE_CA_CERT — path to the fixture CA (secrets/ca.crt).
/// Optional: supplied, the TLS legs pin to it and the CA-pin test runs; absent, the
/// connect legs fall back to AllowUntrustedServerCertificate and the pin test
/// skips.
///
/// -
/// MQTT_FIXTURE_TOPIC_PREFIX — must match the publisher's. Default
/// otopcua/fixture.
///
///
///
/// The probe is one-shot and its socket is closed immediately: tests open their own real
/// / sessions, and sharing a socket
/// would serialise them.
///
///
public sealed class MqttFixture
{
/// The gate: absent ⇒ the whole suite skips.
public const string EndpointEnvVar = "MQTT_FIXTURE_ENDPOINT";
/// Optional override for the plaintext listener; defaults to the TLS host on 1883.
public const string PlainEndpointEnvVar = "MQTT_FIXTURE_PLAIN_ENDPOINT";
/// Broker username; defaults to the generator script's own default.
public const string UsernameEnvVar = "MQTT_FIXTURE_USERNAME";
/// Broker password. No default — a defaulted fixture password is a committed secret.
public const string PasswordEnvVar = "MQTT_FIXTURE_PASSWORD";
/// Path to the fixture CA PEM, for the CA-pin legs.
public const string CaCertEnvVar = "MQTT_FIXTURE_CA_CERT";
/// Topic prefix the fixture publisher emits under.
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);
/// Initializes a new instance of the class.
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);
}
/// Gets the TLS listener host.
public string TlsHost { get; } = string.Empty;
/// Gets the TLS listener port.
public int TlsPort { get; }
/// Gets the plaintext listener host.
public string PlainHost { get; } = string.Empty;
/// Gets the plaintext listener port.
public int PlainPort { get; }
/// Gets the broker username.
public string Username { get; }
/// Gets the broker password, or when unconfigured.
public string? Password { get; }
/// Gets the fixture CA PEM path, or when not supplied.
public string? CaCertificatePath { get; }
/// Gets the topic prefix the fixture publisher emits under (no trailing slash).
public string TopicPrefix { get; }
///
/// Gets the reason every test in the suite must skip, or when the
/// fixture is configured and reachable.
///
public string? SkipReason { get; }
/// Gets a value indicating whether the suite must skip.
public bool NotConfigured => SkipReason is not null;
/// Composes a fixture topic under .
/// The topic suffix, e.g. line1/temperature.
/// The full topic.
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'`.";
}
///
/// 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.
///
[Xunit.CollectionDefinition(Name)]
public sealed class MqttFixtureCollection : Xunit.ICollectionFixture
{
/// The collection name.
public const string Name = "MqttFixture";
}