fix(dcl): ASCII-only connection actor names, guarded ActorOf, and gate rejection of colliding connection names (review 2026-10-21 lane A M2)

ConnectionActorNames.Sanitize (shared by the DCL manager and the site deploy
gate) keeps ASCII letters/digits and -_.*+:@&=,!~';() only. The manager fails a
connection whose sanitised name is invalid or already used (Error + site
event) instead of restarting; DeployCompileValidator rejects a config whose
connection names collide after sanitising.
This commit is contained in:
Joseph Doherty
2026-10-22 09:05:07 -04:00
parent 3c11510c52
commit 099b2221d6
6 changed files with 210 additions and 6 deletions
@@ -0,0 +1,92 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
/// <summary>
/// Review 2026-10-21 (lane A M2): a data connection name becomes its DataConnectionActor's
/// name. The old sanitiser kept non-ASCII letters and '$' (both refused by Akka) and mapped
/// "PLC A" and "PLC-A" to the same name; either made <c>Context.ActorOf</c> throw on the
/// manager's own thread, restarting it and dropping every live connection.
/// </summary>
[Collection("DataConnectionManagerActor")]
public class ConnectionActorNameTests : TestKit
{
private readonly IDataConnectionFactory _factory = Substitute.For<IDataConnectionFactory>();
private readonly ISiteHealthCollector _health = Substitute.For<ISiteHealthCollector>();
private readonly DataConnectionOptions _options = new()
{
ReconnectInterval = TimeSpan.FromMilliseconds(100),
TagResolutionRetryInterval = TimeSpan.FromMilliseconds(200)
};
/// <summary>Creates the test kit with a factory that hands out connected mock adapters.</summary>
public ConnectionActorNameTests()
{
_factory.Create(Arg.Any<string>(), Arg.Any<IDictionary<string, string>>()).Returns(_ =>
{
var adapter = Substitute.For<IDataConnection>();
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
adapter.Status.Returns(ConnectionHealth.Connected);
adapter.SubscribeAsync(Arg.Any<string>(), Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>())
.Returns("sub-1");
return adapter;
});
}
/// <summary>Every sanitised name is a legal Akka path element, including non-ASCII and '$' input.</summary>
[Theory]
[InlineData("PLC A")]
[InlineData("Ofen Süd")]
[InlineData("$PLC")]
[InlineData("名前")]
[InlineData("a/b#c[d]%e")]
[InlineData("")]
public void Sanitize_AlwaysYieldsAValidActorName(string connectionName)
{
Assert.True(ActorPath.IsValidPathElement(ConnectionActorNames.Sanitize(connectionName)));
}
private IActorRef CreateManager() => Sys.ActorOf(Props.Create(() =>
new DataConnectionManagerActor(_factory, _options, _health)));
private SubscribeTagsResponse Subscribe(IActorRef manager, string connection)
{
manager.Tell(new SubscribeTagsRequest(
Guid.NewGuid().ToString("N"), "inst1", connection, ["tag1"], DateTimeOffset.UtcNow));
return ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(10));
}
/// <summary>A non-ASCII connection name is created instead of crashing the manager.</summary>
[Fact]
public void NonAsciiConnectionName_IsCreated()
{
var manager = CreateManager();
manager.Tell(new CreateConnectionCommand("Ofen Süd", "OpcUa", new Dictionary<string, string>(), null, 3));
var response = Subscribe(manager, "Ofen Süd");
Assert.DoesNotContain("Unknown connection", response.ErrorMessage ?? "");
}
/// <summary>
/// Two names that sanitise alike: the second fails alone; the first — and the manager —
/// keep working.
/// </summary>
[Fact]
public void CollidingSanitisedNames_FailOnlyTheLaterConnection()
{
var manager = CreateManager();
manager.Tell(new CreateConnectionCommand("PLC A", "OpcUa", new Dictionary<string, string>(), null, 3));
manager.Tell(new CreateConnectionCommand("PLC-A", "OpcUa", new Dictionary<string, string>(), null, 3));
Assert.Contains("Unknown connection", Subscribe(manager, "PLC-A").ErrorMessage);
Assert.DoesNotContain("Unknown connection", Subscribe(manager, "PLC A").ErrorMessage ?? "");
}
}
@@ -143,4 +143,25 @@ public class DeployCompileValidatorTests
Assert.Contains(nameErrors, e => e.Contains("Hi/Temp"));
Assert.Contains(nameErrors, e => e.Contains("Src#1"));
}
/// <summary>
/// Lane A M2: connection names that sanitise to the same DataConnectionActor name are
/// rejected by the gate; distinct ones (including non-ASCII) pass.
/// </summary>
[Fact]
public void ConnectionNames_CollidingAfterSanitising_AreRejected()
{
static string Cfg(params string[] names) => JsonSerializer.Serialize(new FlattenedConfiguration
{
Scripts = [], Alarms = [], NativeAlarmSources = [], Attributes = [],
Connections = names.ToDictionary(n => n, _ => new ConnectionConfig { Protocol = "OpcUa" })
});
var collide = NewValidator().Validate(Cfg("PLC A", "PLC-A")).Where(DeployCompileValidator.IsActorNameError).ToList();
Assert.Single(collide);
Assert.Contains("PLC A", collide[0]);
Assert.Contains("PLC-A", collide[0]);
Assert.Empty(NewValidator().Validate(Cfg("PLC A", "Ofen Süd")));
}
}