using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
///
/// archreview 06/S-11 — verifies the net-new (built on
/// the shared ZB.MOM.WW.Configuration OptionsValidatorBase/ValidationBuilder).
/// The fail tier is deliberately narrow: only provably-crashing configs fail — an enabled
/// historian (or an enabled AlarmHistorian, which sources its gateway connection from the
/// ServerHistorian section) with an empty / non-absolute / non-http(s) Endpoint, i.e.
/// exactly the configs that would otherwise throw deep in the
/// gateway client factory at startup. Empty ApiKey / non-positive MaxTieClusterOverfetch
/// stay operator warnings in (they degrade, not crash).
///
public sealed class ServerHistorianOptionsValidatorTests
{
private static ServerHistorianOptionsValidator Sut(bool alarmHistorianEnabled = false)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary
{
["AlarmHistorian:Enabled"] = alarmHistorianEnabled ? "true" : "false",
})
.Build();
return new ServerHistorianOptionsValidator(configuration);
}
/// Enabled historian with an empty endpoint fails (would crash-loop in the factory).
[Fact]
public void Enabled_with_empty_endpoint_fails()
{
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "" });
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
}
/// Enabled with a relative (non-absolute) URI fails.
[Fact]
public void Enabled_with_relative_uri_fails()
{
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "host:5222" });
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
}
/// Enabled with a non-http(s) scheme (ftp) fails.
[Fact]
public void Enabled_with_bad_scheme_fails()
{
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "ftp://host:5222" });
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
}
///
/// Enabled with a valid absolute https endpoint and a key succeeds. The key is not
/// incidental: this case previously omitted it and still asserted success, which pinned a config
/// that actually crash-loops the host — see .
///
[Fact]
public void Enabled_with_valid_https_endpoint_succeeds()
{
var result = Sut().Validate(null, new ServerHistorianOptions
{
Enabled = true, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret",
});
result.Succeeded.ShouldBeTrue();
}
/// A disabled section may legitimately be empty — no failure.
[Fact]
public void Disabled_with_empty_endpoint_succeeds()
{
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "" });
result.Succeeded.ShouldBeTrue();
}
///
/// The residual corner: ServerHistorian:Enabled=false but AlarmHistorian:Enabled=true
/// (which sources its connection from the ServerHistorian section) with an empty endpoint fails —
/// and the message names BOTH sections so the operator understands why a disabled section is required.
///
[Fact]
public void Disabled_but_alarm_historian_enabled_with_empty_endpoint_fails_naming_both_sections()
{
var result = Sut(alarmHistorianEnabled: true)
.Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "" });
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f =>
f.Contains("ServerHistorian:Endpoint") && f.Contains("AlarmHistorian:Enabled"));
}
/// Alarm-only mode with a valid endpoint and a key succeeds.
[Fact]
public void Disabled_but_alarm_historian_enabled_with_valid_endpoint_succeeds()
{
var result = Sut(alarmHistorianEnabled: true)
.Validate(null, new ServerHistorianOptions
{
Enabled = false, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret",
});
result.Succeeded.ShouldBeTrue();
}
///
/// An empty ApiKey on a consumed section fails, because it crashes — it does not
/// degrade.
///
///
///
/// Found by the LocalDb Phase 2 live gate. The rig booted with
/// AlarmHistorian:Enabled=true and a valid endpoint but no key, and every driver
/// node crash-looped on
/// ArgumentException: The gateway API key must not be empty (Parameter 'ApiKey')
/// thrown by HistorianGatewayClientOptions.Validate() — reached through
/// GatewayHistorian.CreateAlarmWriter during Akka startup.
///
///
/// That is the *same* factory path, and the same crash-loop-under-a-restart-policy
/// failure mode, this validator was built to convert into a named
/// OptionsValidationException. The previous "empty ApiKey degrades, the gateway
/// just rejects calls" premise was simply wrong: the client validates its own options
/// at construction, so the process never reaches the point of making a call.
///
///
[Fact]
public void Consumed_with_empty_api_key_fails()
{
var result = Sut(alarmHistorianEnabled: true)
.Validate(null, new ServerHistorianOptions
{
Enabled = false, Endpoint = "https://host:5222", ApiKey = "",
});
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f =>
f.Contains("ServerHistorian:ApiKey") && f.Contains("AlarmHistorian:Enabled"));
}
/// The read path has the identical crash, so it is gated identically.
[Fact]
public void Enabled_with_empty_api_key_fails()
{
var result = Sut().Validate(null, new ServerHistorianOptions
{
Enabled = true, Endpoint = "https://host:5222", ApiKey = "",
});
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:ApiKey"));
}
///
/// The key must never be echoed. An endpoint is not a secret and is quoted to make the error
/// actionable; a key is, so the failure names only the setting.
///
[Fact]
public void Api_key_failure_never_echoes_the_key()
{
var result = Sut().Validate(null, new ServerHistorianOptions
{
Enabled = true, Endpoint = "https://host:5222", ApiKey = " ",
});
result.Failed.ShouldBeTrue();
result.Failures.ShouldNotContain(f => f.Contains(" ", StringComparison.Ordinal));
}
/// A section nobody consumes may be keyless — no failure.
[Fact]
public void Disabled_with_empty_api_key_succeeds()
{
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = false, ApiKey = "" });
result.Succeeded.ShouldBeTrue();
}
///
/// UseTls=true against an http:// endpoint fails — the third member of the
/// crash-at-construction family, and the easiest of the three for an operator to trip.
///
///
/// Also found by the Phase 2 live gate, immediately after the ApiKey fix: the rig points
/// at a deliberately unresolvable http:// endpoint, and UseTls defaults to
/// , so every node crash-looped a second time on
/// ArgumentException: UseTls requires an https gateway endpoint. Switching an endpoint
/// from https to http without clearing UseTls is an ordinary migration slip, and it takes
/// the host down rather than degrading it.
///
[Fact]
public void Consumed_with_use_tls_against_http_endpoint_fails()
{
var result = Sut().Validate(null, new ServerHistorianOptions
{
Enabled = true, Endpoint = "http://host:5222", ApiKey = "histgw_id_secret", UseTls = true,
});
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f =>
f.Contains("ServerHistorian:UseTls") && f.Contains("http://host:5222"));
}
/// An http endpoint with TLS explicitly off is the valid h2c shape — no failure.
[Fact]
public void Consumed_with_http_endpoint_and_tls_off_succeeds()
{
var result = Sut().Validate(null, new ServerHistorianOptions
{
Enabled = true, Endpoint = "http://host:5222", ApiKey = "histgw_id_secret", UseTls = false,
});
result.Succeeded.ShouldBeTrue();
}
///
/// The mirror slip — UseTls=false against an https:// endpoint — also fails, so the
/// scheme and the flag are pinned to agree in both directions rather than in one.
///
[Fact]
public void Consumed_with_tls_off_against_https_endpoint_fails()
{
var result = Sut().Validate(null, new ServerHistorianOptions
{
Enabled = true, Endpoint = "https://host:5222", ApiKey = "histgw_id_secret", UseTls = false,
});
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:UseTls"));
}
///
/// Wiring guard (the "register-AND-consume" trap): resolving IOptions<ServerHistorianOptions>.Value
/// after throws
/// for an enabled+empty section — proving the validator is
/// attached to the options pipeline (not merely defined). ValidateOnStart reaching host start
/// is already proven by the LDAP precedent.
///
[Fact]
public void AddValidatedOptions_wiring_throws_on_enabled_empty_endpoint()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary
{
["ServerHistorian:Enabled"] = "true",
["ServerHistorian:Endpoint"] = "",
})
.Build();
var services = new ServiceCollection();
services.AddSingleton(configuration);
services.AddValidatedOptions(
configuration, ServerHistorianOptions.SectionName);
using var provider = services.BuildServiceProvider();
var ex = Should.Throw(
() => _ = provider.GetRequiredService>().Value);
ex.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
}
}