diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/MeshRegistrationRoleScopingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/MeshRegistrationRoleScopingTests.cs new file mode 100644 index 00000000..e9e16a4f --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/MeshRegistrationRoleScopingTests.cs @@ -0,0 +1,169 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration; + +/// +/// Security register item 3 (DI role-scoping pin). IMeshPskProvider and +/// IMeshCommandDialer are central's per-cluster key lookup + outbound command dialer — +/// a driver-only node holds exactly its own MeshTransport:GrpcPsk and never dials +/// another node, so these two singletons must be registered ONLY inside one of Program.cs's +/// if (hasAdmin) blocks. Nothing in the language enforces that placement; this is a +/// source scan so a refactor that hoists either registration out of its block fails loudly +/// here instead of silently widening the mesh trust boundary to driver-only nodes. +/// +/// +/// Program.cs gates several unrelated concerns behind repeated if (hasAdmin) checks +/// (auth/AdminUI mounting, health probes, gRPC endpoint mapping, ...), so this pin cannot +/// assume there is only one such block in the file — it must find the SPECIFIC block that +/// contains each registration and prove that containment is unambiguous. +/// +[Trait("Category", "Unit")] +public sealed class MeshRegistrationRoleScopingTests +{ + private const string PskRegistration = + "AddSingleton"; + + private const string DialerRegistration = + "AddSingleton"; + + private const string HasAdminMarker = "if (hasAdmin)"; + + private static string ReadProgramSource() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) + dir = dir.Parent; + dir.ShouldNotBeNull(); + + return File.ReadAllText(Path.Combine( + dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.Host", "Program.cs")); + } + + private static int CountOccurrences(string haystack, string needle) + { + var count = 0; + var index = 0; + while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += needle.Length; + } + + return count; + } + + /// + /// Brace-matches every if (hasAdmin) block in the file (not a regex over the block + /// body — the body is free-form C# and a regex would be fragile to reformatting) and + /// returns each block's (open-brace, close-brace) character range. + /// + private static List<(int Start, int End)> FindHasAdminBlockRanges(string source) + { + var ranges = new List<(int Start, int End)>(); + var searchIndex = 0; + while (true) + { + var markerIndex = source.IndexOf(HasAdminMarker, searchIndex, StringComparison.Ordinal); + if (markerIndex < 0) + break; + + var openBraceIndex = source.IndexOf('{', markerIndex + HasAdminMarker.Length); + openBraceIndex.ShouldBeGreaterThan(-1, + $"'{HasAdminMarker}' at offset {markerIndex} is not followed by an opening brace."); + + var depth = 0; + var closeBraceIndex = -1; + for (var i = openBraceIndex; i < source.Length; i++) + { + if (source[i] == '{') + depth++; + else if (source[i] == '}') + { + depth--; + if (depth == 0) + { + closeBraceIndex = i; + break; + } + } + } + + closeBraceIndex.ShouldBeGreaterThan(-1, + $"brace-matching for the '{HasAdminMarker}' block starting at offset {markerIndex} never closed."); + + ranges.Add((openBraceIndex, closeBraceIndex)); + searchIndex = closeBraceIndex + 1; + } + + return ranges; + } + + /// + /// Locates the single if (hasAdmin) block that contains . + /// Fails loudly — rather than silently picking the first match — if the registration is + /// missing entirely, sits outside every admin-role block, or (should a future edit make two + /// blocks both contain matching text) the containing block is ambiguous. Any of those is a + /// reason to stop and re-examine the pin, not to guess. + /// + private static string ExtractContainingHasAdminBlock(string source, string registration, string whyItMatters) + { + var ranges = FindHasAdminBlockRanges(source); + ranges.ShouldNotBeEmpty( + $"Program.cs no longer contains any '{HasAdminMarker}' block — the admin-role gate this " + + $"pin relies on is gone. {whyItMatters}"); + + var registrationIndex = source.IndexOf(registration, StringComparison.Ordinal); + registrationIndex.ShouldBeGreaterThan(-1, + $"'{registration}' was not found anywhere in Program.cs. {whyItMatters}"); + + var containingBlocks = ranges + .Where(r => registrationIndex >= r.Start && registrationIndex <= r.End) + .ToList(); + + containingBlocks.ShouldNotBeEmpty( + $"'{registration}' is registered OUTSIDE every '{HasAdminMarker}' block. {whyItMatters}"); + + containingBlocks.Count.ShouldBe(1, + $"'{registration}' falls inside {containingBlocks.Count} '{HasAdminMarker}' blocks, so " + + "which one actually gates it is ambiguous — extend this pin to disambiguate rather than " + + $"trusting the first match. {whyItMatters}"); + + var (start, end) = containingBlocks[0]; + return source.Substring(start, end - start + 1); + } + + [Fact] + public void MeshPskProvider_IsRegisteredExactlyOnce_InsideAnUnambiguousHasAdminBlock() + { + const string why = + "A driver-only node must not carry central's per-cluster key lookup, which it can never " + + "legitimately use."; + var source = ReadProgramSource(); + + // Global uniqueness first: if this text appeared twice, block-containment below could pass + // even though a second, unguarded copy exists elsewhere in the file. + CountOccurrences(source, PskRegistration).ShouldBe(1, + $"IMeshPskProvider must be registered exactly once in Program.cs. {why}"); + + var block = ExtractContainingHasAdminBlock(source, PskRegistration, why); + block.ShouldContain(PskRegistration, Case.Sensitive, + $"IMeshPskProvider must be registered inside 'if (hasAdmin)'. {why}"); + } + + [Fact] + public void MeshGrpcCommandDialer_IsRegisteredExactlyOnce_InsideAnUnambiguousHasAdminBlock() + { + const string why = + "A driver-only node never dials another node and must not carry central's outbound mesh " + + "command dialer."; + var source = ReadProgramSource(); + + CountOccurrences(source, DialerRegistration).ShouldBe(1, + $"IMeshCommandDialer must be registered exactly once in Program.cs. {why}"); + + var block = ExtractContainingHasAdminBlock(source, DialerRegistration, why); + block.ShouldContain(DialerRegistration, Case.Sensitive, + $"IMeshCommandDialer must be registered inside 'if (hasAdmin)'. {why}"); + } +}