feat(historian): AddServerHistorian DI + Host wiring of IHistorianDataSource
This commit is contained in:
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime;
|
||||
@@ -30,6 +31,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
private readonly DeferredAddressSpaceSink _deferredSink;
|
||||
private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
|
||||
private readonly IOpcUaUserAuthenticator _userAuthenticator;
|
||||
private readonly IHistorianDataSource _historianDataSource;
|
||||
private readonly Func<ActorSystem> _actorSystemAccessor;
|
||||
private readonly ActorRegistry _actorRegistry;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
@@ -45,6 +47,11 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
/// <param name="deferredSink">The deferred address space sink that receives the real sink once the server is ready.</param>
|
||||
/// <param name="deferredServiceLevel">The deferred service level publisher that receives the real publisher once the server is ready.</param>
|
||||
/// <param name="userAuthenticator">The OPC UA user authenticator.</param>
|
||||
/// <param name="historianDataSource">The server-side HistoryRead backend resolved from DI — the
|
||||
/// <c>NullHistorianDataSource</c> default seeded by <c>AddOtOpcUaRuntime</c> (which runs on this driver
|
||||
/// node, the same source the address-space sink + node-write gateway come from), or the configured
|
||||
/// Wonderware read client when <c>AddServerHistorian</c> enabled it. Wired onto the node manager in
|
||||
/// <see cref="StartAsync"/>.</param>
|
||||
/// <param name="actorSystemAccessor">Lazy accessor for the running <see cref="ActorSystem"/>, used to
|
||||
/// resolve the DistributedPubSub mediator the inbound alarm-command router publishes through. Resolved
|
||||
/// lazily (mirroring <c>DpsScriptLogPublisher</c>) so construction never races Akka startup.</param>
|
||||
@@ -57,6 +64,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
DeferredAddressSpaceSink deferredSink,
|
||||
DeferredServiceLevelPublisher deferredServiceLevel,
|
||||
IOpcUaUserAuthenticator userAuthenticator,
|
||||
IHistorianDataSource historianDataSource,
|
||||
Func<ActorSystem> actorSystemAccessor,
|
||||
ActorRegistry actorRegistry,
|
||||
ILoggerFactory loggerFactory)
|
||||
@@ -65,6 +73,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
_deferredSink = deferredSink;
|
||||
_deferredServiceLevel = deferredServiceLevel;
|
||||
_userAuthenticator = userAuthenticator;
|
||||
_historianDataSource = historianDataSource;
|
||||
_actorSystemAccessor = actorSystemAccessor;
|
||||
_actorRegistry = actorRegistry;
|
||||
_loggerFactory = loggerFactory;
|
||||
@@ -148,6 +157,11 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
resolveDriverHost: () => _actorRegistry.TryGet<DriverHostActorKey>(out var driverHost) ? driverHost : null,
|
||||
logger: _loggerFactory.CreateLogger<ActorNodeWriteGateway>()));
|
||||
|
||||
// Wire the server-side read backend resolved from DI — the NullHistorianDataSource default (when
|
||||
// the ServerHistorian section is disabled) or the configured Wonderware read client (when enabled).
|
||||
// The node manager's HistoryRead overrides block-bridge to whatever source is set here.
|
||||
_server.SetHistorianDataSource(_historianDataSource);
|
||||
|
||||
// ServiceLevel publisher needs IServerInternal — only available after Start.
|
||||
if (_server.CurrentInstance is { } serverInternal)
|
||||
{
|
||||
@@ -171,6 +185,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
_deferredServiceLevel.SetInner(null);
|
||||
// Restore the Null write gateway so a late client write doesn't Ask a stopping DriverHostActor.
|
||||
_server?.SetNodeWriteGateway(null);
|
||||
// Restore the Null historian so a late HistoryRead doesn't hit a disposed read client.
|
||||
_server?.SetHistorianDataSource(null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,20 @@ if (hasDriver)
|
||||
},
|
||||
sp.GetService<ILogger<WonderwareHistorianClient>>()));
|
||||
|
||||
// Config-gated server-side HistoryRead backend. When the ServerHistorian section is enabled this
|
||||
// overrides the NullHistorianDataSource default from AddOtOpcUaRuntime (last registration wins) with
|
||||
// a read-only WonderwareHistorianClient the node manager's HistoryRead overrides block-bridge to.
|
||||
// The client is supplied here because the Host is the only project that references the Wonderware
|
||||
// client — Runtime owns the gating, the Host supplies the concrete read downstream.
|
||||
builder.Services.AddServerHistorian(
|
||||
builder.Configuration,
|
||||
(opts, sp) => new WonderwareHistorianClient(
|
||||
new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret)
|
||||
{
|
||||
UseTls = opts.UseTls, ServerCertThumbprint = opts.ServerCertThumbprint,
|
||||
},
|
||||
sp.GetService<ILogger<WonderwareHistorianClient>>()));
|
||||
|
||||
// Bind every cross-platform driver factory before AddAkka resolves IDriverFactory — replaces
|
||||
// the F7-default NullDriverFactory with a real DriverFactoryRegistryAdapter so DriverHostActor
|
||||
// can materialise real IDriver instances on deploy.
|
||||
|
||||
@@ -22,5 +22,13 @@
|
||||
"DrainIntervalSeconds": 5,
|
||||
"Capacity": 1000000,
|
||||
"DeadLetterRetentionDays": 30
|
||||
},
|
||||
"ServerHistorian": {
|
||||
"Enabled": false,
|
||||
"Host": "localhost",
|
||||
"Port": 32569,
|
||||
"UseTls": false,
|
||||
"ServerCertThumbprint": null,
|
||||
"SharedSecret": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
@@ -59,6 +60,28 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire the server-side HistoryRead backend (the <see cref="IHistorianDataSource"/> the node
|
||||
/// manager's HistoryRead overrides block-bridge to) onto the created
|
||||
/// <see cref="OtOpcUaNodeManager"/>. The host calls this after start with the DI-resolved source —
|
||||
/// the <c>NullHistorianDataSource</c> default (GoodNoData-empty reads) or the configured Wonderware
|
||||
/// read client. Passing <c>null</c> restores the Null default (the property setter null-coalesces),
|
||||
/// i.e. "no historian". No-op (returns <c>false</c>) when the node manager has not been created yet,
|
||||
/// so the caller can detect a too-early call (mirrors <see cref="SetNodeWriteGateway"/>).
|
||||
/// </summary>
|
||||
/// <param name="source">The read backend invoked by the node manager's HistoryRead overrides; may be
|
||||
/// <c>null</c> to restore the Null default (GoodNoData-empty reads).</param>
|
||||
/// <returns><c>true</c> when the source was set on a live node manager; <c>false</c> when no node
|
||||
/// manager exists yet.</returns>
|
||||
public bool SetHistorianDataSource(IHistorianDataSource? source)
|
||||
{
|
||||
if (_otOpcUaNodeManager is null) return false;
|
||||
// The HistorianDataSource setter null-coalesces to the Null default, so a null source is intentional
|
||||
// (restores GoodNoData-empty reads); forgive the nullable-in here.
|
||||
_otOpcUaNodeManager.HistorianDataSource = source!;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override MasterNodeManager CreateMasterNodeManager(
|
||||
IServerInternal server, ApplicationConfiguration configuration)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the <c>ServerHistorian</c> configuration section that gates the server-side
|
||||
/// HistoryRead backend. When <see cref="Enabled"/> is <c>true</c>, <c>AddServerHistorian</c>
|
||||
/// registers a read-only <c>WonderwareHistorianClient</c> (supplied by the Host) as the
|
||||
/// <c>IHistorianDataSource</c> in place of the <c>NullHistorianDataSource</c> default;
|
||||
/// otherwise the Null default survives and HistoryRead returns <c>GoodNoData</c>-empty.
|
||||
/// <para>
|
||||
/// This is the READ path only — there are no DatabasePath / drain / capacity / retention
|
||||
/// knobs (those belong to the write-side <c>AlarmHistorian</c> store-and-forward sink). The
|
||||
/// client's own <c>CallTimeout</c> bounds each read; the node manager adds no extra timeout.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ServerHistorianOptions
|
||||
{
|
||||
/// <summary>The configuration section name this options class binds.</summary>
|
||||
public const string SectionName = "ServerHistorian";
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, the Wonderware read client is registered as the
|
||||
/// <c>IHistorianDataSource</c>; when <c>false</c> (the default) the no-op
|
||||
/// <c>NullHistorianDataSource</c> stays in place and HistoryRead returns empty.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>TCP hostname or IP address the Wonderware historian sidecar listens on.</summary>
|
||||
public string Host { get; init; } = "localhost";
|
||||
|
||||
/// <summary>TCP port the Wonderware historian sidecar listens on.</summary>
|
||||
public int Port { get; init; }
|
||||
|
||||
/// <summary>When <c>true</c>, the client connects over TLS.</summary>
|
||||
public bool UseTls { get; init; }
|
||||
|
||||
/// <summary>Expected TLS server certificate thumbprint (hex, no spaces). Null or empty disables pinning.</summary>
|
||||
public string? ServerCertThumbprint { get; init; }
|
||||
|
||||
/// <summary>Per-process shared secret the sidecar verifies in the Hello frame.</summary>
|
||||
public string SharedSecret { get; init; } = "";
|
||||
|
||||
/// <summary>Returns operator-facing misconfiguration warnings for an <c>Enabled</c> historian
|
||||
/// (empty when disabled or correctly configured). Pure — the registration logs each entry.</summary>
|
||||
/// <returns>Zero or more human-readable warning messages.</returns>
|
||||
public IEnumerable<string> Validate()
|
||||
{
|
||||
if (!Enabled) yield break;
|
||||
if (string.IsNullOrWhiteSpace(SharedSecret))
|
||||
yield return "ServerHistorian:SharedSecret is empty while the historian is enabled — the Wonderware sidecar Hello frame will carry an empty secret.";
|
||||
if (Port <= 0)
|
||||
yield return $"ServerHistorian:Port is {Port} — must be > 0; the read client cannot dial the sidecar.";
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddOtOpcUaRuntime(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<IAlarmHistorianSink>(NullAlarmHistorianSink.Instance);
|
||||
services.TryAddSingleton<IHistorianDataSource>(NullHistorianDataSource.Instance);
|
||||
services.TryAddSingleton<IDriverFactory>(NullDriverFactory.Instance);
|
||||
services.TryAddSingleton<IOpcUaAddressSpaceSink>(NullOpcUaAddressSpaceSink.Instance);
|
||||
services.TryAddSingleton<IServiceLevelPublisher>(NullServiceLevelPublisher.Instance);
|
||||
@@ -93,6 +94,38 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Config-gated server-side HistoryRead backend. When the <c>ServerHistorian</c> section has
|
||||
/// <c>Enabled=true</c>, registers the <paramref name="dataSourceFactory"/>-supplied
|
||||
/// <see cref="IHistorianDataSource"/> (the read-only Wonderware client) overriding the
|
||||
/// <see cref="NullHistorianDataSource"/> default from <see cref="AddOtOpcUaRuntime"/>. Otherwise
|
||||
/// a no-op (the Null default stays and the node manager's HistoryRead returns
|
||||
/// <c>GoodNoData</c>-empty). The data source is injected so the Wonderware client can be supplied
|
||||
/// by the Host, which is the only project that references it.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register with.</param>
|
||||
/// <param name="configuration">The configuration carrying the <c>ServerHistorian</c> section.</param>
|
||||
/// <param name="dataSourceFactory">
|
||||
/// Factory the Host supplies to build the concrete read <see cref="IHistorianDataSource"/>
|
||||
/// (the Wonderware client) from the bound options + the resolving provider.
|
||||
/// </param>
|
||||
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
|
||||
public static IServiceCollection AddServerHistorian(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
Func<ServerHistorianOptions, IServiceProvider, IHistorianDataSource> dataSourceFactory)
|
||||
{
|
||||
var opts = configuration.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>();
|
||||
if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime
|
||||
|
||||
foreach (var warning in opts.Validate())
|
||||
Serilog.Log.Logger.ForContext<ServerHistorianOptions>().Warning("ServerHistorian config: {ServerHistorianConfigWarning}", warning);
|
||||
|
||||
// Last-registration-wins over the TryAddSingleton Null default seeded by AddOtOpcUaRuntime.
|
||||
services.AddSingleton<IHistorianDataSource>(sp => dataSourceFactory(opts, sp));
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns the per-node driver-role actors on the host's <see cref="ActorSystem"/>:
|
||||
/// <see cref="DriverHostActor"/> (one per node), <see cref="DbHealthProbeActor"/>
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the config-gated <c>AddServerHistorian</c> registration: when the
|
||||
/// <c>ServerHistorian</c> section is absent or disabled the <see cref="NullHistorianDataSource"/>
|
||||
/// default seeded by <c>AddOtOpcUaRuntime</c> survives (the factory is never invoked); when it is
|
||||
/// enabled the factory's returned <see cref="IHistorianDataSource"/> wins
|
||||
/// (last-registration-wins over the <c>TryAddSingleton</c> Null default).
|
||||
/// </summary>
|
||||
public sealed class AddServerHistorianTests
|
||||
{
|
||||
/// <summary>A trivial read source the factory hands back when enabled; never actually invoked.</summary>
|
||||
private sealed class FakeHistorianDataSource : IHistorianDataSource
|
||||
{
|
||||
private static readonly HistoryReadResult EmptyRead = new(Array.Empty<DataValueSnapshot>(), null);
|
||||
private static readonly HistoricalEventsResult EmptyEvents = new(Array.Empty<HistoricalEvent>(), null);
|
||||
|
||||
public Task<HistoryReadResult> ReadRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken) => Task.FromResult(EmptyRead);
|
||||
|
||||
public Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken) => Task.FromResult(EmptyRead);
|
||||
|
||||
public Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc,
|
||||
CancellationToken cancellationToken) => Task.FromResult(EmptyRead);
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
||||
CancellationToken cancellationToken) => Task.FromResult(EmptyEvents);
|
||||
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => new(
|
||||
TotalQueries: 0, TotalSuccesses: 0, TotalFailures: 0, ConsecutiveFailures: 0,
|
||||
LastSuccessTime: null, LastFailureTime: null, LastError: null,
|
||||
ProcessConnectionOpen: false, EventConnectionOpen: false,
|
||||
ActiveProcessNode: null, ActiveEventNode: null,
|
||||
Nodes: Array.Empty<HistorianClusterNodeState>());
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Stateless fake; nothing to release.
|
||||
}
|
||||
}
|
||||
|
||||
private static IConfiguration ConfigFrom(Dictionary<string, string?> values)
|
||||
=> new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
|
||||
[Fact]
|
||||
public void Section_absent_keeps_null_source_and_factory_not_invoked()
|
||||
{
|
||||
var factoryInvoked = false;
|
||||
var services = new ServiceCollection();
|
||||
services.AddOtOpcUaRuntime();
|
||||
var config = ConfigFrom(new Dictionary<string, string?>());
|
||||
|
||||
services.AddServerHistorian(config, (_, _) =>
|
||||
{
|
||||
factoryInvoked = true;
|
||||
return new FakeHistorianDataSource();
|
||||
});
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
provider.GetRequiredService<IHistorianDataSource>().ShouldBeSameAs(NullHistorianDataSource.Instance);
|
||||
factoryInvoked.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Section_disabled_keeps_null_source_and_factory_not_invoked()
|
||||
{
|
||||
var factoryInvoked = false;
|
||||
var services = new ServiceCollection();
|
||||
services.AddOtOpcUaRuntime();
|
||||
var config = ConfigFrom(new Dictionary<string, string?>
|
||||
{
|
||||
["ServerHistorian:Enabled"] = "false",
|
||||
});
|
||||
|
||||
services.AddServerHistorian(config, (_, _) =>
|
||||
{
|
||||
factoryInvoked = true;
|
||||
return new FakeHistorianDataSource();
|
||||
});
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
provider.GetRequiredService<IHistorianDataSource>().ShouldBeSameAs(NullHistorianDataSource.Instance);
|
||||
factoryInvoked.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Section_enabled_registers_factory_source()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOtOpcUaRuntime();
|
||||
var config = ConfigFrom(new Dictionary<string, string?>
|
||||
{
|
||||
["ServerHistorian:Enabled"] = "true",
|
||||
["ServerHistorian:Host"] = "historian.example.com",
|
||||
["ServerHistorian:Port"] = "32569",
|
||||
["ServerHistorian:SharedSecret"] = "s",
|
||||
});
|
||||
|
||||
services.AddServerHistorian(config, (_, _) => new FakeHistorianDataSource());
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var resolved = provider.GetRequiredService<IHistorianDataSource>();
|
||||
resolved.ShouldBeOfType<FakeHistorianDataSource>();
|
||||
resolved.ShouldNotBeSameAs(NullHistorianDataSource.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Section_enabled_passes_bound_options_to_factory()
|
||||
{
|
||||
ServerHistorianOptions? seen = null;
|
||||
var services = new ServiceCollection();
|
||||
services.AddOtOpcUaRuntime();
|
||||
var config = ConfigFrom(new Dictionary<string, string?>
|
||||
{
|
||||
["ServerHistorian:Enabled"] = "true",
|
||||
["ServerHistorian:Host"] = "historian.example.com",
|
||||
["ServerHistorian:Port"] = "12345",
|
||||
["ServerHistorian:UseTls"] = "true",
|
||||
["ServerHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF",
|
||||
["ServerHistorian:SharedSecret"] = "s",
|
||||
});
|
||||
|
||||
services.AddServerHistorian(config, (opts, _) =>
|
||||
{
|
||||
seen = opts;
|
||||
return new FakeHistorianDataSource();
|
||||
});
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
_ = provider.GetRequiredService<IHistorianDataSource>();
|
||||
|
||||
seen.ShouldNotBeNull();
|
||||
seen.Host.ShouldBe("historian.example.com");
|
||||
seen.Port.ShouldBe(12345);
|
||||
seen.UseTls.ShouldBeTrue();
|
||||
seen.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_warns_on_empty_shared_secret_when_enabled()
|
||||
{
|
||||
var opts = new ServerHistorianOptions { Enabled = true, SharedSecret = "", Port = 32569 };
|
||||
opts.Validate().ShouldContain(w => w.Contains("SharedSecret"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_warns_on_non_positive_port_when_enabled()
|
||||
{
|
||||
var opts = new ServerHistorianOptions { Enabled = true, SharedSecret = "s", Port = 0 };
|
||||
opts.Validate().ShouldContain(w => w.Contains("Port"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_is_silent_when_correctly_configured()
|
||||
{
|
||||
new ServerHistorianOptions { Enabled = true, SharedSecret = "s", Port = 32569 }.Validate().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_is_silent_when_disabled()
|
||||
{
|
||||
new ServerHistorianOptions { Enabled = false, SharedSecret = "", Port = 0 }.Validate().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Section_binds_host_port_tls_fields()
|
||||
{
|
||||
var config = ConfigFrom(new Dictionary<string, string?>
|
||||
{
|
||||
["ServerHistorian:Host"] = "historian.example.com",
|
||||
["ServerHistorian:Port"] = "12345",
|
||||
["ServerHistorian:UseTls"] = "true",
|
||||
["ServerHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF",
|
||||
});
|
||||
|
||||
var opts = config.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>();
|
||||
|
||||
opts.ShouldNotBeNull();
|
||||
opts.Host.ShouldBe("historian.example.com");
|
||||
opts.Port.ShouldBe(12345);
|
||||
opts.UseTls.ShouldBeTrue();
|
||||
opts.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user