Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/ActorNameRulesParityTests.cs
T
Joseph Doherty f5090fcd9e fix(site-runtime): reject invalid runtime actor names at the site gate and never let ActorOf escalate to the Deployment Manager (review 2026-10-21 01/SR-C1)
An instance unique name Akka refuses as a path element (space, '#', '/',
non-ASCII) made Context.ActorOf throw on the singleton's own thread; the
default Restart stopped every Instance Actor on the site. DeployCompileValidator
now checks the instance + script/alarm/native-source actor names with
ActorPath.IsValidPathElement (the Commons ActorNameRules mirror is stricter
than Akka for " ( ), pinned by a parity sweep), CreateInstanceActor catches
InvalidActorNameException and every caller reports it, and the Instance Actor
skips a refused child name like a compile failure.
2026-10-22 09:10:26 -04:00

155 lines
5.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text;
using Akka.Actor;
using ZB.MOM.WW.ScadaBridge.Commons.Validation;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Deployment;
/// <summary>
/// Pins <see cref="ActorNameRules"/> (Commons, which cannot reference Akka) to the rule it
/// mirrors, <see cref="ActorPath.IsValidPathElement"/>. The site deploy gate rejects names
/// through <see cref="ActorNameRules"/> on the strength of this equivalence (review 2026-10-21
/// 01/SR-C1): a name the mirror accepts but Akka refuses would slip past the gate and fail at
/// <c>Context.ActorOf</c>; a name the mirror refuses but Akka accepts would block a deploy that
/// would have worked. Run on this test project because it is the nearest one referencing both.
/// </summary>
public class ActorNameRulesParityTests
{
/// <summary>Printable ASCII 0x20–0x7E.</summary>
private static IEnumerable<char> PrintableAscii()
{
for (var c = (char)0x20; c <= (char)0x7E; c++)
yield return c;
}
/// <summary>
/// Characters Akka.NET 1.5 accepts in a path element that <see cref="ActorNameRules"/> does
/// not (found by this sweep, 2026-10-22). The mirror is STRICTER here — it can only refuse a
/// name Akka would have created, never admit one Akka refuses — which is why the site deploy
/// gate (<c>DeployCompileValidator</c>) decides with <see cref="ActorPath.IsValidPathElement"/>
/// itself rather than the mirror. Reported for the Commons owner to widen the mirror; once
/// it does, this set simply stops matching anything and the test keeps passing.
/// </summary>
private const string KnownMirrorStricterChars = "\"()";
private static void AssertParity(string candidate, List<string> disagreements)
{
var mirror = ActorNameRules.IsValid(candidate);
var akka = ActorPath.IsValidPathElement(candidate);
if (mirror != akka && !IsKnownMirrorStricterGap(candidate, mirror, akka))
{
disagreements.Add(
$"\"{Escape(candidate)}\": ActorNameRules={mirror}, Akka={akka}");
}
}
/// <summary>
/// True when the disagreement is exactly the documented gap: the mirror refuses, Akka
/// accepts, and replacing every <see cref="KnownMirrorStricterChars"/> character with a
/// letter makes the two agree again.
/// </summary>
private static bool IsKnownMirrorStricterGap(string candidate, bool mirror, bool akka)
{
if (mirror || !akka || candidate.IndexOfAny(KnownMirrorStricterChars.ToCharArray()) < 0)
return false;
var patched = new string(candidate
.Select(c => KnownMirrorStricterChars.Contains(c) ? 'a' : c).ToArray());
return ActorNameRules.IsValid(patched) == ActorPath.IsValidPathElement(patched);
}
/// <summary>
/// Akka accepts every known-gap character, so a name such as <c>Pump(A)</c> is deployable —
/// which is why the site gate must not decide with the stricter mirror.
/// </summary>
[Theory]
[InlineData("\"")]
[InlineData("(")]
[InlineData(")")]
[InlineData("Pump(A)")]
public void KnownGapCharacters_AreAcceptedByAkka(string candidate)
{
Assert.True(ActorPath.IsValidPathElement(candidate));
}
private static string Escape(string s)
{
var sb = new StringBuilder();
foreach (var c in s)
sb.Append(c is < (char)0x20 or > (char)0x7E ? $"\\u{(int)c:X4}" : c.ToString());
return sb.ToString();
}
/// <summary>Every printable ASCII character alone, and with a letter on either side.</summary>
[Fact]
public void SingleCharacters_AgreeWithAkka()
{
var disagreements = new List<string>();
foreach (var c in PrintableAscii())
{
AssertParity(c.ToString(), disagreements);
AssertParity("a" + c, disagreements);
AssertParity(c + "a", disagreements);
}
Assert.True(disagreements.Count == 0, string.Join(Environment.NewLine, disagreements));
}
/// <summary>Every ordered pair of printable ASCII characters (95 × 95).</summary>
[Fact]
public void CharacterPairs_AgreeWithAkka()
{
var disagreements = new List<string>();
foreach (var a in PrintableAscii())
foreach (var b in PrintableAscii())
AssertParity(new string([a, b]), disagreements);
Assert.True(disagreements.Count == 0, string.Join(Environment.NewLine, disagreements));
}
/// <summary>Percent-escapes, leading '$', empty/null-ish and non-ASCII samples.</summary>
[Theory]
[InlineData("")]
[InlineData("%41")]
[InlineData("a%41b")]
[InlineData("%4")]
[InlineData("%")]
[InlineData("a%")]
[InlineData("%%41")]
[InlineData("%G1")]
[InlineData("%1G")]
[InlineData("%aF")]
[InlineData("%41%42")]
[InlineData("$")]
[InlineData("$a")]
[InlineData("a$")]
[InlineData("a$b")]
[InlineData("-a")]
[InlineData("Furnace #3")]
[InlineData("Line 1")]
[InlineData("a b")]
[InlineData("Ofen Süd")]
[InlineData("Ünit")]
[InlineData("名前")]
[InlineData("é")]
[InlineData("a\tb")]
[InlineData("a\nb")]
[InlineData("script-Module.Member")]
[InlineData("alarm-HiTemp")]
[InlineData("native-alarm-Conn.Source")]
[InlineData("Pump_01;x'y~z!")]
public void Samples_AgreeWithAkka(string candidate)
{
var disagreements = new List<string>();
AssertParity(candidate, disagreements);
Assert.True(disagreements.Count == 0, string.Join(Environment.NewLine, disagreements));
}
/// <summary>Null is invalid on both sides of the mirror.</summary>
[Fact]
public void Null_IsInvalid()
{
Assert.False(ActorNameRules.IsValid(null));
Assert.NotNull(ActorNameRules.Describe(null));
}
}