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")); } }