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_ENDPOINThost:port of the TLS listener, e.g. /// 10.100.0.35:8883. The gate: absent ⇒ the whole suite skips. /// /// /// MQTT_FIXTURE_PLAIN_ENDPOINThost: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"; }