Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Models/IedBrowserReadPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,24 @@ public static IReadOnlyList<IedBrowserReadTarget> Build(
};
}

// Page payloads can outlive a discovery/association change. Never turn
// their detached DA objects into MMS targets merely because they contain
// a plausible reference. Resolve every selection back through THIS live
// model's exact MMS reference and FC, and use the model's declared type.
// Duplicate identities in the model are ambiguous and fail closed.
var authorized = all
.Where(a => !string.IsNullOrWhiteSpace(a.MmsReference))
.GroupBy(a => (a.MmsReference, Fc: a.FunctionalConstraint.ToUpperInvariant()))
.Where(group => group.Count() == 1)
.ToDictionary(group => group.Key, group => group.Single());

var attributes = selected.Where(a => ReadableFc.Contains(a.FunctionalConstraint))
.Where(a => !string.IsNullOrWhiteSpace(a.MmsReference))
.Select(a => authorized.GetValueOrDefault(
(a.MmsReference, a.FunctionalConstraint.ToUpperInvariant())))
.Where(a => a is not null)
.Select(a => new IedBrowserReadTarget(
a.MmsReference, a.FunctionalConstraint,
a!.MmsReference, a.FunctionalConstraint,
string.IsNullOrWhiteSpace(a.SclBType) ? a.MmsType : a.SclBType));
// Exact, model-inventory-proven control state is an additional
// read-only projection. It is never added to cyclic or Read All scans.
Expand Down
110 changes: 110 additions & 0 deletions tests/ARSAS.Tests/P0HBrowserModelInventoryAuthorityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using AR.Iec61850.Discovery;
using ArIED61850Tester.Models;

namespace ARSAS.Tests;

/// <summary>
/// Ensures Browser selection never trusts stale/detached UI nodes as MMS inventory.
/// All targets and types must originate in the active live discovery model.
/// </summary>
public sealed class P0HBrowserModelInventoryAuthorityTests
{
private const string Prefix = "IEDLD0/XCBR1";

private static LiveIedDataAttributeModel Leaf(
string leaf, string fc, string type = "Boolean") => new()
{
ObjectReference = Prefix + ".Pos",
AttributePath = "Pos." + leaf,
MmsReference = Prefix + "$" + fc + "$Pos$" + leaf,
FunctionalConstraint = fc,
SclBType = type,
MmsType = type,
TypeDiscoveryStatus = "Exact"
};

private static LiveIedModelDiscoveryDocument Model(
params LiveIedDataAttributeModel[] attributes) => new()
{
LogicalDevices =
[
new LiveIedLogicalDeviceModel
{
MmsDomain = "IEDLD0",
LogicalNodes =
[
new LiveIedLogicalNodeModel
{
Name = "XCBR1",
LnClass = "XCBR",
DataObjects =
[
new LiveIedDataObjectModel
{
Name = "Pos",
Reference = Prefix + ".Pos",
Attributes = attributes
}
]
}
]
}
]
};

private static IedBrowserTreeRow DetachedPage(
params LiveIedDataAttributeModel[] attributes) =>
new(Prefix + ".Pos", "Pos", "DO", 2, new LiveIedDataObjectModel
{
Name = "Pos",
Reference = Prefix + ".Pos",
Attributes = attributes
});

[Fact]
public void DetachedUiPage_CannotInjectUnknownSvOrCoReferences()
{
var live = Model(Leaf("subEna", "SV"), Leaf("stVal", "ST"));
var page = DetachedPage(
Leaf("subEna", "SV", "ForgedDataType"),
Leaf("opRcvd", "OR"), // not in active model
Leaf("blkEna", "BL"), // not in active model
Leaf("Oper", "CO")); // control is never a scalar Read
var selected = IedBrowserReadPlan.Build(live, page, readAll: false);
var target = Assert.Single(selected);
Assert.Equal(Prefix + "$SV$Pos$subEna", target.Reference);
Assert.Equal("SV", target.FunctionalConstraint);
Assert.Equal("Boolean", target.DataType); // type comes from live model, not UI
}

[Fact]
public void SameReferenceWrongFc_IsNotAuthorizedByCatalog()
{
var live = Model(Leaf("stVal", "ST"));
var page = DetachedPage(Leaf("stVal", "SV"));
Assert.Empty(IedBrowserReadPlan.Build(live, page, readAll: false));
}

[Fact]
public void DuplicateModelWireIdentity_FailsClosedInsteadOfChoosingFirstType()
{
var live = Model(Leaf("subVal", "SV", "Dbpos"),
Leaf("subVal", "SV", "Boolean"));
var page = DetachedPage(Leaf("subVal", "SV", "Dbpos"));
Assert.Empty(IedBrowserReadPlan.Build(live, page, readAll: false));
Assert.Empty(IedBrowserReadPlan.Build(live, null, readAll: true));
}

[Fact]
public void LegitimateLiveModel_StillReadsExactSvOrBlAndSt()
{
var live = Model(Leaf("subEna", "SV"),
Leaf("opOk", "OR"), Leaf("blkEna", "BL"), Leaf("stVal", "ST"));
var page = DetachedPage(Leaf("subEna", "SV"),
Leaf("opOk", "OR"), Leaf("blkEna", "BL"), Leaf("stVal", "ST"));
var selected = IedBrowserReadPlan.Build(live, page, readAll: false);
Assert.Equal(4, selected.Count);
Assert.Equal(new[] { "SV", "OR", "BL", "ST" },
selected.Select(target => target.FunctionalConstraint));
}
}
Loading