265 lines
12 KiB
C#
265 lines
12 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
|
|
|
|
/// <summary>
|
|
/// Security register item 3 (DI role-scoping pin). <c>IMeshPskProvider</c> and
|
|
/// <c>IMeshCommandDialer</c> are central's per-cluster key lookup + outbound command dialer —
|
|
/// a driver-only node holds exactly its own <c>MeshTransport:GrpcPsk</c> and never dials
|
|
/// another node, so these two singletons must be registered ONLY inside one of Program.cs's
|
|
/// <c>if (hasAdmin)</c> 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Program.cs gates several unrelated concerns behind repeated <c>if (hasAdmin)</c> 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. One of the six
|
|
/// markers (the ConfigDb gate) is a braceless single-statement <c>if</c>; the scanner handles
|
|
/// that shape explicitly rather than scanning forward for a brace that belongs to unrelated
|
|
/// code (see <see cref="FindHasAdminBlockRanges"/>). The scanner is also NOT string/comment
|
|
/// aware — a literal <c>{</c>/<c>}</c>/<c>;</c> inside a string or comment inside a scanned
|
|
/// span would misscan; it survives today only because no such span contains one, and the
|
|
/// implausible-length tripwire below is the cheap backstop for exactly this class of mistake.
|
|
/// </remarks>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class MeshRegistrationRoleScopingTests
|
|
{
|
|
private const string PskRegistration =
|
|
"AddSingleton<IMeshPskProvider, MeshPskProvider>";
|
|
|
|
private const string DialerRegistration =
|
|
"AddSingleton<IMeshCommandDialer, MeshGrpcCommandDialer>";
|
|
|
|
private const string HasAdminMarker = "if (hasAdmin)";
|
|
|
|
// The real hasAdmin blocks in Program.cs top out around 40 lines (the mesh-registration block
|
|
// itself). 150 leaves ample margin for legitimate growth while still catching a mis-scan: the
|
|
// phantom-block defect this tripwire was added for fabricated a ~290-line range by adopting an
|
|
// unrelated if-block's brace.
|
|
private const int MaxPlausibleBlockLines = 150;
|
|
|
|
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("could not locate the repository root (no ZB.MOM.WW.OtOpcUa.slnx above the test binary)");
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scans every <c>if (hasAdmin)</c> occurrence 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 one's body as a (start, end) character range. C# permits an <c>if</c> with
|
|
/// no braces at all when the body is a single statement (Program.cs's ConfigDb gate is
|
|
/// exactly this shape), so each occurrence is classified by what actually follows it:
|
|
/// </summary>
|
|
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 bodyStart = markerIndex + HasAdminMarker.Length;
|
|
while (bodyStart < source.Length && char.IsWhiteSpace(source[bodyStart]))
|
|
bodyStart++;
|
|
bodyStart.ShouldBeLessThan(source.Length,
|
|
$"'{HasAdminMarker}' at offset {markerIndex} has no body — reached end of file.");
|
|
|
|
(int Start, int End) range;
|
|
if (source[bodyStart] == '{')
|
|
range = ScanBracedBlock(source, bodyStart, markerIndex);
|
|
else
|
|
// NOT a fallback IndexOf('{', ...) scan: the next brace in the file belongs to
|
|
// whatever code comes after this statement, not to this if — adopting it fabricates
|
|
// a phantom block spanning unrelated code (the exact defect this scanner exists to
|
|
// catch when it happens to a REAL registration).
|
|
range = ScanBracelessStatement(source, bodyStart, markerIndex);
|
|
|
|
var lineCount = 1 + CountOccurrences(source.Substring(range.Start, range.End - range.Start + 1), "\n");
|
|
lineCount.ShouldBeLessThanOrEqualTo(MaxPlausibleBlockLines,
|
|
$"the scanned '{HasAdminMarker}' body starting at offset {range.Start} spans {lineCount} " +
|
|
"lines, which is implausibly long for one of this file's admin-role gates — almost " +
|
|
"certainly a mis-scan (e.g. a braceless if adopting an unrelated block's brace) rather " +
|
|
"than a real block. Do not trust this range; fix the scanner instead.");
|
|
|
|
ranges.Add(range);
|
|
searchIndex = range.End + 1;
|
|
}
|
|
|
|
return ranges;
|
|
}
|
|
|
|
private static (int Start, int End) ScanBracedBlock(string source, int openBraceIndex, int markerIndex)
|
|
{
|
|
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.");
|
|
|
|
return (openBraceIndex, closeBraceIndex);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bounds a braceless single-statement <c>if</c> body to its terminating <c>;</c>, tracking
|
|
/// paren/brace depth so a semicolon nested inside the statement itself (e.g. a lambda or
|
|
/// object initializer in the call's arguments) does not end the scan early.
|
|
/// </summary>
|
|
private static (int Start, int End) ScanBracelessStatement(string source, int statementStart, int markerIndex)
|
|
{
|
|
var depth = 0;
|
|
var semicolonIndex = -1;
|
|
for (var i = statementStart; i < source.Length; i++)
|
|
{
|
|
var c = source[i];
|
|
if (c is '(' or '{')
|
|
depth++;
|
|
else if (c is ')' or '}')
|
|
depth--;
|
|
else if (c == ';' && depth == 0)
|
|
{
|
|
semicolonIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
semicolonIndex.ShouldBeGreaterThan(-1,
|
|
$"the braceless '{HasAdminMarker}' single-statement body starting at offset {markerIndex} " +
|
|
"never terminated with ';'.");
|
|
|
|
return (statementStart, semicolonIndex);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Locates the single <c>if (hasAdmin)</c> block that contains <paramref name="registration"/>.
|
|
/// 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.
|
|
/// </summary>
|
|
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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pins the scanner's expected result shape against today's Program.cs — 5 markers,
|
|
/// 4 braced blocks + the 1 braceless ConfigDb gate, so 5 scanned ranges (the data-plane
|
|
/// key-store branch moved into <c>DataPlaneAuthRegistration</c> for arch-review 03/S18, taking
|
|
/// one braced marker with it). Without this, a
|
|
/// scanner bug that silently drops or merges a marker (as the pre-fix version did to the
|
|
/// braceless marker — it fabricated a phantom range spanning an unrelated if-block and swallowed
|
|
/// a real nested 'if (hasAdmin)' block inside it, yielding 5 ranges instead of 6) would not
|
|
/// surface unless it happened to also break one of the two registration tests above.
|
|
/// </summary>
|
|
[Fact]
|
|
public void HasAdminMarkerScan_MatchesTheKnownShapeOfProgramCs()
|
|
{
|
|
var source = ReadProgramSource();
|
|
|
|
CountOccurrences(source, HasAdminMarker).ShouldBe(5,
|
|
"Program.cs's 'if (hasAdmin)' marker count changed. If a marker was added or removed, " +
|
|
"update this pin — and check whether the new/removed occurrence is braced or braceless — " +
|
|
"rather than letting the scanner silently mis-shape its result.");
|
|
|
|
var ranges = FindHasAdminBlockRanges(source);
|
|
ranges.Count.ShouldBe(5,
|
|
"expected one scanned range per 'if (hasAdmin)' marker, including the braceless ConfigDb " +
|
|
"gate. A mismatch means the scanner adopted an unrelated brace or otherwise merged/dropped " +
|
|
"a marker instead of handling each one independently.");
|
|
}
|
|
}
|