diff --git a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json
index ea61746a..0b287f11 100644
--- a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json
+++ b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json
@@ -4,13 +4,13 @@
"tasks": [
{
"id": "T1",
- "subject": "RED: misconfig repro test \u2014 HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)",
+ "subject": "RED: misconfig repro test — HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)",
"status": "completed",
"blockedBy": []
},
{
"id": "T2",
- "subject": "GREEN: adapter guard \u2014 Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)",
+ "subject": "GREEN: adapter guard — Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)",
"status": "completed",
"blockedBy": [
"T1"
@@ -19,7 +19,7 @@
{
"id": "T3",
"subject": "RED: ServerHistorianOptionsValidator unit tests (7 cases incl. the AlarmHistorian-enabled/ServerHistorian-disabled corner naming both sections)",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
"T1"
]
@@ -50,7 +50,7 @@
},
{
"id": "T7",
- "subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType \u2014 0.2.0 proto3-optional presence witness; pin, no RED phase)",
+ "subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType — 0.2.0 proto3-optional presence witness; pin, no RED phase)",
"status": "pending",
"blockedBy": []
},
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs
new file mode 100644
index 00000000..d374c6b9
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ServerHistorianOptionsValidatorTests.cs
@@ -0,0 +1,137 @@
+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 succeeds.
+ [Fact]
+ public void Enabled_with_valid_https_endpoint_succeeds()
+ {
+ var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "https://host:5222" });
+
+ 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 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" });
+
+ result.Succeeded.ShouldBeTrue();
+ }
+
+ ///
+ /// 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"));
+ }
+}