fix(high-severity): close 9 of 10 open High findings across 8 modules

Comm-016: delete dead HandleConnectionStateChanged + _debugSubscriptions /
_inProgressDeployments tracking + ConnectionStateChanged message record.
Disconnect detection is owned by the transport layers (gRPC keepalive PING
~25s; Ask-timeout at CommunicationService). Updates the
Component-Communication.md design doc to make that explicit.

SnF-018: NotificationForwarder.DeliverAsync now discards a corrupt buffered
payload (Warning log + return true) instead of returning false and parking
the row — honoring the design's "notifications do not park" invariant.

DM-018: reconciliation no longer force-sets Enabled, preserving an
intentional Disabled state after central failover.

ESG-018: DeliverBufferedAsync (both ExternalSystemClient + DatabaseGateway)
catches JsonException and returns false, turning a corrupt buffered row
into a parked operation instead of a retry-forever poison message.

InboundAPI-022: register ActiveNodeGate as IActiveNodeGate in the Central
DI branch so standby-node gating is actually wired up in production.

NS-019: remove orphaned NotificationDeliveryService /
INotificationDeliveryService / NotificationResult; central notification
delivery now lives entirely in NotificationOutbox.

SEL-016: normalise From/To filters to UTC before ISO-string compare so
non-UTC DateTimeOffset clients no longer get spuriously excluded events.

TE-017: include Description on attributes/alarms and a HashableConnections
projection (protocol, endpoint JSON, failover count) in the revision hash
and DiffService; staleness detection now catches description-only and
connection-endpoint edits.

Transport-001 and Transport-002 (also High) remain Open — they're being
handled in a follow-up batch because both touch BundleImporter.cs and
must serialise.
This commit is contained in:
Joseph Doherty
2026-05-28 05:40:15 -04:00
parent f936f55f51
commit ac96b83b08
38 changed files with 852 additions and 1729 deletions
@@ -230,17 +230,10 @@ public class CompatibilityTests
// ── Round-trip serialization for all key message types ──
[Fact]
public void RoundTrip_ConnectionStateChanged_Succeeds()
{
var msg = new ConnectionStateChanged("site-01", true, DateTimeOffset.UtcNow);
var json = JsonSerializer.Serialize(msg);
var deserialized = JsonSerializer.Deserialize<ConnectionStateChanged>(json, Options);
Assert.NotNull(deserialized);
Assert.Equal("site-01", deserialized!.SiteId);
Assert.True(deserialized.IsConnected);
}
// Communication-016: RoundTrip_ConnectionStateChanged_Succeeds removed
// alongside the dead ConnectionStateChanged message record. No production
// code emits or receives this message — disconnect detection is owned by
// the gRPC keepalive and the Ask-timeout path.
[Fact]
public void RoundTrip_AlarmStateChanged_Succeeds()
@@ -116,30 +116,13 @@ public class CentralCommunicationActorTests : TestKit
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
[Fact]
public void ConnectionLost_DebugStreamsKilled()
{
var site = CreateSite("site1", "akka.tcp://scadalink@host:8082");
var (actor, _, siteProbes) = CreateActorWithMockRepo(new[] { site });
// Wait for auto-refresh
Thread.Sleep(1000);
// Subscribe to debug view (tracks the subscription)
var subscriberProbe = CreateTestProbe();
var subRequest = new SubscribeDebugViewRequest("inst1", "corr-123");
actor.Tell(new SiteEnvelope("site1", subRequest), subscriberProbe.Ref);
// The ClusterClient probe receives the routed message
siteProbes["site1"].ExpectMsg<ClusterClient.Send>();
// Simulate site disconnection
actor.Tell(new ConnectionStateChanged("site1", false, DateTimeOffset.UtcNow));
// The subscriber should receive a DebugStreamTerminated notification
subscriberProbe.ExpectMsg<DebugStreamTerminated>(
msg => msg.SiteId == "site1" && msg.CorrelationId == "corr-123");
}
// Communication-016: the prior `ConnectionLost_DebugStreamsKilled` test was
// removed alongside the dead HandleConnectionStateChanged handler. No
// production code ever emitted ConnectionStateChanged, so the test was
// exercising a workflow that never ran. Disconnect detection is owned by
// the gRPC keepalive (DebugStreamBridgeActor self-terminates) and by the
// Ask-timeout path at the CommunicationService layer (deploy callers see
// a failure).
[Fact]
public void Heartbeat_BumpsAggregatorTimestamp()
@@ -820,6 +820,56 @@ public class DeploymentServiceTests : TestKit
Assert.Equal("sha256:target", storedSnapshot.RevisionHash);
}
// ── DeploymentManager-018: reconciliation must preserve an intentional Disabled state ──
[Fact]
public async Task DeployInstanceAsync_Reconciled_DisabledInstance_PreservesDisabledState()
{
// DeploymentManager-018: after a central failover, the in-memory
// OperationLockManager is lost (by design — in-progress treated as
// failed). The prior deployment record remains InProgress in the DB.
// The operator can legitimately invoke Disable on the instance between
// the timed-out deploy and the redeploy. Disable does not change the
// deployed config, so the site still reports the target revision hash.
// When the operator retries the deploy, the reconciliation branch must
// NOT silently overwrite Instance.State back to Enabled — that would
// undo the explicit operator action with no audit trail.
var instance = new Instance("ReconcileDisabled")
{
Id = 72, SiteId = 1, State = InstanceState.Disabled
};
_repo.GetInstanceByIdAsync(72, Arg.Any<CancellationToken>()).Returns(instance);
SetupValidPipeline(72, "ReconcileDisabled", "sha256:target");
var prior = new DeploymentRecord("dep-prior-72", "admin")
{
InstanceId = 72,
Status = DeploymentStatus.InProgress,
RevisionHash = "sha256:target"
};
_repo.GetCurrentDeploymentStatusAsync(72, Arg.Any<CancellationToken>()).Returns(prior);
_repo.GetDeployedSnapshotByInstanceIdAsync(72, Arg.Any<CancellationToken>())
.Returns((DeployedConfigSnapshot?)null);
var commActor = Sys.ActorOf(Props.Create(() =>
new ReconcileProbeActor(siteHash: "sha256:target", failQuery: false)));
var service = CreateServiceWithCommActor(commActor);
var result = await service.DeployInstanceAsync(72, "admin");
// The reconciliation still succeeds and the prior record is marked
// Success — central and site agree on the applied config.
Assert.True(result.IsSuccess);
Assert.Equal(DeploymentStatus.Success, prior.Status);
Assert.Equal(1, ReconcileProbeActor.QueryCount);
Assert.Equal(0, ReconcileProbeActor.DeployCount);
// DeploymentManager-018: the operator's explicit Disable must survive
// the reconciliation — Instance.State stays Disabled, not silently
// flipped to Enabled.
Assert.Equal(InstanceState.Disabled, instance.State);
}
// ── DeploymentManager-016: reconciled record must carry the target revision hash ──
[Fact]
@@ -207,6 +207,31 @@ public class DatabaseGatewayTests
Assert.False(delivered); // permanent — the S&F engine parks the message
}
// ── ExternalSystemGateway-018: malformed JSON payload must park, not retry-forever ──
[Fact]
public async Task DeliverBuffered_MalformedJsonPayload_ReturnsFalseSoMessageParks()
{
// No connection stub needed — deserialization fails before any
// resolution or SQL execution. If the JsonException were to escape (the
// pre-018 behaviour) the S&F engine would treat it as transient and
// retry the same poison row forever.
var gateway = new DatabaseGateway(_repository, NullLogger<DatabaseGateway>.Instance);
var poisonMessage = new ScadaLink.StoreAndForward.StoreAndForwardMessage
{
Id = Guid.NewGuid().ToString("N"),
Category = ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.CachedDbWrite,
Target = "someDb",
// Truncated mid-write — `{` opens an object that never closes.
PayloadJson = "{\"ConnectionName\":\"someDb\",\"Sql\":\"INSERT",
};
var delivered = await gateway.DeliverBufferedAsync(poisonMessage);
Assert.False(delivered); // permanent — the S&F engine parks the message
}
// ── ExternalSystemGateway-010: SqlConnection must not leak when OpenAsync fails ──
[Fact]
@@ -234,6 +234,32 @@ public class ExternalSystemClientTests
() => client.DeliverBufferedAsync(BufferedCall("TestAPI", "failMethod")));
}
// ── ExternalSystemGateway-018: malformed JSON payload must park, not retry-forever ──
[Fact]
public async Task DeliverBuffered_MalformedJsonPayload_ReturnsFalseSoMessageParks()
{
// No repository / HTTP stubs needed — deserialization fails before any
// resolution or HTTP call. If the JsonException were to escape (the
// pre-018 behaviour) the S&F engine would treat it as transient and
// retry the same poison row forever.
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
var poisonMessage = new StoreAndForwardMessage
{
Id = Guid.NewGuid().ToString("N"),
Category = ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem,
Target = "TestAPI",
// Truncated mid-write — `{` opens an object that never closes.
PayloadJson = "{\"SystemName\":\"TestAPI\",\"MethodName\":\"get",
};
var delivered = await client.DeliverBufferedAsync(poisonMessage);
Assert.False(delivered); // permanent — the S&F engine parks the message
}
// ── ExternalSystemGateway-003: CachedCall must not double-dispatch ──
[Fact]
@@ -17,6 +17,7 @@ using ScadaLink.ExternalSystemGateway;
using ScadaLink.HealthMonitoring;
using ScadaLink.Host;
using ScadaLink.Host.Actors;
using ScadaLink.Host.Health;
using ScadaLink.InboundAPI;
using ScadaLink.ManagementService;
using ScadaLink.NotificationService;
@@ -204,9 +205,13 @@ public class CentralCompositionRootTests : IDisposable
new object[] { typeof(IExternalSystemClient) },
new object[] { typeof(DatabaseGateway) },
new object[] { typeof(IDatabaseGateway) },
// NotificationService
new object[] { typeof(NotificationDeliveryService) },
new object[] { typeof(INotificationDeliveryService) },
// NotificationService — central-only SMTP primitives. The orphan
// NotificationDeliveryService + INotificationDeliveryService were removed
// (NS-019) when sites stopped delivering notifications; the central
// EmailNotificationDeliveryAdapter is now the only resolver of these
// primitives.
new object[] { typeof(Func<ISmtpClientWrapper>) },
new object[] { typeof(OAuth2TokenService) },
// ConfigurationDatabase repositories
new object[] { typeof(ScadaLinkDbContext) },
new object[] { typeof(ISecurityRepository) },
@@ -277,6 +282,30 @@ public class CentralCompositionRootTests : IDisposable
var hostedServices = _factory.Services.GetServices<IHostedService>();
Assert.Contains(hostedServices, s => s.GetType() == typeof(CentralHealthAggregator));
}
// --- InboundAPI-022 regression ---
/// <summary>
/// InboundAPI-022 regression: the Central composition root MUST register a
/// concrete <see cref="IActiveNodeGate"/> implementation. Without it,
/// <see cref="InboundApiEndpointFilter"/> falls through to "allow" and a
/// standby central node continues to serve the inbound API, racing the
/// active node and executing scripts against stale singleton state. The
/// design's "central cluster only (active node)" guarantee is enforced only
/// when the production gate is wired here.
///
/// Structural check on the built provider (not just <see cref="IServiceCollection"/>)
/// — a registration the framework cannot resolve to a concrete instance is
/// indistinguishable from "missing" at runtime, which is the failure mode
/// the finding describes.
/// </summary>
[Fact]
public void Central_IActiveNodeGate_IsRegisteredAsActiveNodeGate()
{
var gate = _factory.Services.GetService<IActiveNodeGate>();
Assert.NotNull(gate);
Assert.IsType<ActiveNodeGate>(gate);
}
}
/// <summary>
@@ -5,11 +5,9 @@ using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.InboundAPI;
using ScadaLink.NotificationService;
namespace ScadaLink.IntegrationTests;
@@ -98,42 +96,11 @@ public class IntegrationSurfaceTests
}
// ── Notification: mock SMTP delivery ──
[Fact]
public async Task Notification_Send_MockSmtp_Delivers()
{
var repository = Substitute.For<INotificationRepository>();
var smtpClient = Substitute.For<ISmtpClientWrapper>();
var list = new NotificationList("alerts") { Id = 1 };
var recipients = new List<NotificationRecipient>
{
new("Admin", "admin@example.com") { Id = 1, NotificationListId = 1 }
};
var smtpConfig = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
{
Id = 1, Port = 587, Credentials = "user:pass"
};
repository.GetListByNameAsync("alerts").Returns(list);
repository.GetRecipientsByListIdAsync(1).Returns(recipients);
repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration> { smtpConfig });
var service = new NotificationDeliveryService(
repository,
() => smtpClient,
Microsoft.Extensions.Logging.Abstractions.NullLogger<NotificationDeliveryService>.Instance);
var result = await service.SendAsync("alerts", "Test Alert", "Something happened");
Assert.True(result.Success);
await smtpClient.Received(1).SendAsync(
"noreply@example.com",
Arg.Is<IEnumerable<string>>(r => r.Contains("admin@example.com")),
"Test Alert",
"Something happened",
Arg.Any<CancellationToken>());
}
// NS-019: the site-shaped NotificationDeliveryService that this case exercised
// was removed when sites stopped delivering notifications. The central SMTP
// delivery path is now covered end-to-end by
// ScadaLink.NotificationOutbox.Tests.Delivery.EmailNotificationDeliveryAdapterTests;
// no equivalent integration-surface assertion is needed here.
// ── Script Context: integration API wiring ──
File diff suppressed because it is too large Load Diff
@@ -197,4 +197,58 @@ public class NotificationForwarderTests : TestKit
Assert.Equal(submit1.NotificationId, submit2.NotificationId);
Assert.Equal("stable-msg-id", submit1.NotificationId);
}
[Fact]
public async Task Deliver_CorruptJsonPayload_ReturnsTrue_AndDoesNotForwardAnything()
{
// Regression test for StoreAndForward-018. The design doc forbids parking
// notifications ("notifications do not park — they are retried at the fixed
// forward interval until central acks"; Component-StoreAndForward.md). The
// previous implementation returned false on a corrupt payload, which the S&F
// engine interprets as a permanent failure and parks the row — contradicting
// the invariant. The fix: discard a corrupt buffered notification by
// returning true (engine clears the buffer via its normal success path),
// with a Warning log line carrying the row id and a payload preview.
var centralProbe = CreateTestProbe();
var forwarder = new NotificationForwarder(
centralProbe.Ref, "site-7", ForwardTimeout);
var corrupt = new StoreAndForwardMessage
{
Id = "msg-corrupt",
Category = StoreAndForwardCategory.Notification,
Target = "Operators",
PayloadJson = "{not-valid-json",
OriginInstanceName = "Plant.Pump3",
};
Assert.True(await forwarder.DeliverAsync(corrupt));
// The corrupt-payload path must NOT round-trip to central — no
// NotificationSubmit / no Ask. ExpectNoMsg confirms nothing was forwarded.
centralProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
[Fact]
public async Task Deliver_NullDeserializedPayload_ReturnsTrue_AndDoesNotForwardAnything()
{
// The companion case to corrupt JSON: the payload is valid JSON but
// deserialises to null (e.g. "null"). Same treatment per StoreAndForward-018
// — discard rather than park.
var centralProbe = CreateTestProbe();
var forwarder = new NotificationForwarder(
centralProbe.Ref, "site-7", ForwardTimeout);
var nullPayload = new StoreAndForwardMessage
{
Id = "msg-null",
Category = StoreAndForwardCategory.Notification,
Target = "Operators",
PayloadJson = "null",
OriginInstanceName = "Plant.Pump3",
};
Assert.True(await forwarder.DeliverAsync(nullPayload));
centralProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
}
@@ -133,4 +133,122 @@ public class DiffServiceTests
Assert.Single(diff.ScriptChanges);
Assert.Equal(DiffChangeType.Changed, diff.ScriptChanges[0].ChangeType);
}
[Fact]
public void ComputeDiff_AttributeDescriptionChange_DetectedAsChanged()
{
// TemplateEngine-017: AttributesEqual must compare Description.
var oldConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double", Description = "Original" }
]
};
var newConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double", Description = "Updated" }
]
};
var diff = _sut.ComputeDiff(oldConfig, newConfig);
Assert.True(diff.HasChanges);
Assert.Single(diff.AttributeChanges);
Assert.Equal(DiffChangeType.Changed, diff.AttributeChanges[0].ChangeType);
}
[Fact]
public void ComputeDiff_AlarmDescriptionChange_DetectedAsChanged()
{
// TemplateEngine-017: AlarmsEqual must compare Description.
var oldConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Alarms =
[
new ResolvedAlarm { CanonicalName = "HighTemp", TriggerType = "RangeViolation", Description = "Original" }
]
};
var newConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Alarms =
[
new ResolvedAlarm { CanonicalName = "HighTemp", TriggerType = "RangeViolation", Description = "Updated" }
]
};
var diff = _sut.ComputeDiff(oldConfig, newConfig);
Assert.True(diff.HasChanges);
Assert.Single(diff.AlarmChanges);
Assert.Equal(DiffChangeType.Changed, diff.AlarmChanges[0].ChangeType);
}
[Fact]
public void ConnectionsEqual_IdenticalConfigs_ReturnsTrue()
{
// TemplateEngine-017: ConnectionsEqual is the comparator callers use
// to detect connection-endpoint drift (the diff-view extension that
// surfaces this in the UI is tracked under TemplateEngine-018).
var a = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-a\"}",
BackupConfigurationJson = "{\"endpoint\":\"opc.tcp://host-b\"}",
FailoverRetryCount = 3
};
var b = a with { };
Assert.True(DiffService.ConnectionsEqual(a, b));
}
[Fact]
public void ConnectionsEqual_EndpointEdit_ReturnsFalse()
{
// TemplateEngine-017: primary endpoint JSON edit must surface as a
// change. Without this, deployment redeploys ship a different
// ConnectionConfig with no visible drift signal.
var a = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-a:4840\"}",
FailoverRetryCount = 3
};
var b = a with { ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-b:4840\"}" };
Assert.False(DiffService.ConnectionsEqual(a, b));
}
[Fact]
public void ConnectionsEqual_BackupConfigurationEdit_ReturnsFalse()
{
var a = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{}", BackupConfigurationJson = null, FailoverRetryCount = 3 };
var b = a with { BackupConfigurationJson = "{\"endpoint\":\"opc.tcp://backup\"}" };
Assert.False(DiffService.ConnectionsEqual(a, b));
}
[Fact]
public void ConnectionsEqual_FailoverRetryCountEdit_ReturnsFalse()
{
var a = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{}", FailoverRetryCount = 3 };
var b = a with { FailoverRetryCount = 5 };
Assert.False(DiffService.ConnectionsEqual(a, b));
}
[Fact]
public void ConnectionsEqual_ProtocolEdit_ReturnsFalse()
{
var a = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{}", FailoverRetryCount = 3 };
var b = a with { Protocol = "Modbus" };
Assert.False(DiffService.ConnectionsEqual(a, b));
}
}
@@ -131,6 +131,154 @@ public class RevisionHashServiceTests
}
}
[Fact]
public void ComputeHash_AttributeDescriptionEdit_ChangesHash()
{
// TemplateEngine-017: Description must be folded into the hash so that
// edits to authoring-time documentation (which still travels in the
// deployed payload) flow through the staleness indicator.
var baseAttr = new ResolvedAttribute
{
CanonicalName = "Temperature",
Value = "25",
DataType = "Double",
Description = "Original description"
};
var editedAttr = baseAttr with { Description = "Updated description" };
var configBefore = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
TemplateId = 1,
SiteId = 1,
Attributes = [baseAttr]
};
var configAfter = configBefore with { Attributes = [editedAttr] };
Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
}
[Fact]
public void ComputeHash_AlarmDescriptionEdit_ChangesHash()
{
// TemplateEngine-017: same Description contract applies to alarms.
var baseAlarm = new ResolvedAlarm
{
CanonicalName = "HighTemp",
TriggerType = "RangeViolation",
Description = "Original"
};
var editedAlarm = baseAlarm with { Description = "Updated" };
var configBefore = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
TemplateId = 1,
SiteId = 1,
Alarms = [baseAlarm]
};
var configAfter = configBefore with { Alarms = [editedAlarm] };
Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
}
[Fact]
public void ComputeHash_ConnectionEndpointEdit_ChangesHash()
{
// TemplateEngine-017: a Deployment user editing the primary endpoint
// JSON of a data connection bound to an instance must produce a
// different revision hash. The connection's protocol, primary/backup
// configuration JSON, and failover retry count are all part of the
// deployment package and therefore part of the hash input.
var connectionsBefore = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-a:4840\"}",
BackupConfigurationJson = null,
FailoverRetryCount = 3
}
};
var connectionsAfter = new Dictionary<string, ConnectionConfig>
{
["plc1"] = connectionsBefore["plc1"] with
{
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-b:4840\"}"
}
};
var configBefore = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
TemplateId = 1,
SiteId = 1,
Connections = connectionsBefore
};
var configAfter = configBefore with { Connections = connectionsAfter };
Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
}
[Fact]
public void ComputeHash_ConnectionProtocolEdit_ChangesHash()
{
// TemplateEngine-017: changing protocol must change the hash.
var connectionsBefore = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{}",
FailoverRetryCount = 3
}
};
var connectionsAfter = new Dictionary<string, ConnectionConfig>
{
["plc1"] = connectionsBefore["plc1"] with { Protocol = "Modbus" }
};
var configBefore = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
TemplateId = 1,
SiteId = 1,
Connections = connectionsBefore
};
var configAfter = configBefore with { Connections = connectionsAfter };
Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
}
[Fact]
public void ComputeHash_ConnectionsSameContent_SameHash()
{
// TemplateEngine-017: equal Connections maps must yield the same hash,
// regardless of dictionary iteration order (the SortedDictionary
// projection guards this).
var connections1 = new Dictionary<string, ConnectionConfig>
{
["b"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{\"k\":2}", FailoverRetryCount = 3 },
["a"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{\"k\":1}", FailoverRetryCount = 3 }
};
var connections2 = new Dictionary<string, ConnectionConfig>
{
["a"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{\"k\":1}", FailoverRetryCount = 3 },
["b"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{\"k\":2}", FailoverRetryCount = 3 }
};
var config1 = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
TemplateId = 1,
SiteId = 1,
Connections = connections1
};
var config2 = config1 with { Connections = connections2 };
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
}
private static FlattenedConfiguration CreateConfig(string instanceName, string tempValue)
{
return new FlattenedConfiguration