using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
///
/// The #520 stage-3 deploy gate for scopes. An unresolvable scope is dropped by
/// the permission-trie builder and the deploy still seals green, so the row reads as a live grant in the
/// AdminUI while authorizing nothing — these pin that it fails the deploy instead.
///
[Trait("Category", "Unit")]
public sealed class DraftValidatorNodeAclScopeTests
{
/// The cluster every ACL row under test is authored in.
private const string Own = "c";
/// A second, fully-populated cluster in the same snapshot — the cross-cluster counterparty.
private const string Other = "other";
private const string OwnEquipId = "EQ-000000000000";
private const string OtherEquipId = "EQ-0000000b0000";
private static NodeAcl Acl(NodeAclScopeKind kind, string? scopeId, string id = "acl-1") => new()
{
NodeAclId = id,
ClusterId = Own,
Principal = "svc-line3",
ScopeKind = kind,
ScopeId = scopeId,
PermissionFlags = NodePermissions.Read,
};
///
/// A draft carrying one of each scoped entity in and a mirrored set in
/// . Two clusters, not one, so that a cross-cluster id is genuinely present in the
/// snapshot: if the rule's lookups were cluster-filtered such an id would present as merely
/// unresolved and the containment check could never fire.
///
private static DraftSnapshot Draft(params NodeAcl[] acls) => new()
{
GenerationId = 1,
ClusterId = Own,
NodeAcls = acls,
RawFolders =
[
new RawFolder { RawFolderId = "rf-1", ClusterId = Own, Name = "plant" },
new RawFolder { RawFolderId = "rf-2", ClusterId = Other, Name = "plant2" },
],
DriverInstances =
[
new DriverInstance
{
DriverInstanceId = "di-1", ClusterId = Own, Name = "modbus",
DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-1",
},
new DriverInstance
{
DriverInstanceId = "di-2", ClusterId = Other, Name = "modbus2",
DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-2",
},
],
Devices =
[
new Device { DeviceId = "dev-1", DriverInstanceId = "di-1", Name = "dev1", DeviceConfig = "{}" },
new Device { DeviceId = "dev-2", DriverInstanceId = "di-2", Name = "dev2", DeviceConfig = "{}" },
],
TagGroups =
[
new TagGroup { TagGroupId = "tg-1", DeviceId = "dev-1", Name = "group1" },
new TagGroup { TagGroupId = "tg-2", DeviceId = "dev-2", Name = "group2" },
],
UnsAreas =
[
new UnsArea { UnsAreaId = "area-1", ClusterId = Own, Name = "north" },
new UnsArea { UnsAreaId = "area-2", ClusterId = Other, Name = "south" },
],
UnsLines =
[
new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line3" },
new UnsLine { UnsLineId = "line-2", UnsAreaId = "area-2", Name = "line4" },
],
Equipment =
[
new Equipment
{
EquipmentUuid = Guid.Parse("00000000-0000-0000-0000-0000000000aa"),
EquipmentId = OwnEquipId,
Name = "filler", UnsLineId = "line-1", MachineCode = "m",
},
new Equipment
{
EquipmentUuid = Guid.Parse("0000000b-0000-0000-0000-000000000000"),
EquipmentId = OtherEquipId,
Name = "packer", UnsLineId = "line-2", MachineCode = "m",
},
],
Tags =
[
new Tag
{
TagId = "tag-1", DeviceId = "dev-1", Name = "speed",
DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
},
new Tag
{
TagId = "tag-2", DeviceId = "dev-2", Name = "speed2",
DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
},
],
};
///
/// The fixture itself must trip no rule, or a test asserting on the whole error list is really
/// asserting on unrelated noise. This also pins both hardcoded EquipmentIds against the canonical
/// derivation, which a later edit to the fixture's UUIDs would otherwise silently break.
///
[Fact]
public void The_fixture_with_no_acl_rows_is_clean()
{
DraftValidator.Validate(Draft()).ShouldBeEmpty();
DraftValidator.DeriveEquipmentId(Guid.Parse("00000000-0000-0000-0000-0000000000aa")).ShouldBe(OwnEquipId);
DraftValidator.DeriveEquipmentId(Guid.Parse("0000000b-0000-0000-0000-000000000000")).ShouldBe(OtherEquipId);
}
///
/// The row shape the trie builder drops without a word: a narrower kind with nothing to narrow to.
///
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void A_non_Cluster_kind_without_a_ScopeId_is_a_deploy_error(string? scopeId)
{
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Equipment, scopeId)));
errors.ShouldContain(e => e.Code == "NodeAclScopeIdMissing" && e.Context == "acl-1");
}
///
/// A Cluster grant ignores ScopeId, so a populated one means the author picked the wrong kind and the
/// grant is broader than intended — the direction that must never pass silently.
///
[Fact]
public void A_Cluster_kind_carrying_a_ScopeId_is_a_deploy_error()
{
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Cluster, "EQ-000000000000")));
errors.ShouldContain(e => e.Code == "NodeAclScopeIdUnexpected" && e.Context == "acl-1");
}
[Fact]
public void A_Cluster_kind_with_no_ScopeId_passes()
{
DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Cluster, null)))
.ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal));
}
///
/// One case per scoped kind: a well-formed id that names no such entity must fail. Kept exhaustive
/// because the kind→collection mapping is hand-written, and a kind wired to the wrong collection
/// would still "work" for every id that happens to exist in both.
///
[Theory]
[InlineData(NodeAclScopeKind.UnsArea)]
[InlineData(NodeAclScopeKind.UnsLine)]
[InlineData(NodeAclScopeKind.Equipment)]
[InlineData(NodeAclScopeKind.Tag)]
[InlineData(NodeAclScopeKind.RawFolder)]
[InlineData(NodeAclScopeKind.RawDriver)]
[InlineData(NodeAclScopeKind.RawDevice)]
[InlineData(NodeAclScopeKind.RawTagGroup)]
public void A_ScopeId_naming_no_such_entity_is_a_deploy_error(NodeAclScopeKind kind)
{
var errors = DraftValidator.Validate(Draft(Acl(kind, "does-not-exist")));
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
}
///
/// The counterpart to the theory above: each kind pointed at the id from its OWN collection must
/// pass. Without this the rule could reject everything and still look correct.
///
[Theory]
[InlineData(NodeAclScopeKind.UnsArea, "area-1")]
[InlineData(NodeAclScopeKind.UnsLine, "line-1")]
[InlineData(NodeAclScopeKind.Equipment, OwnEquipId)]
[InlineData(NodeAclScopeKind.Tag, "tag-1")]
[InlineData(NodeAclScopeKind.RawFolder, "rf-1")]
[InlineData(NodeAclScopeKind.RawDriver, "di-1")]
[InlineData(NodeAclScopeKind.RawDevice, "dev-1")]
[InlineData(NodeAclScopeKind.RawTagGroup, "tg-1")]
public void A_ScopeId_naming_a_real_entity_of_its_own_kind_passes(NodeAclScopeKind kind, string scopeId)
{
DraftValidator.Validate(Draft(Acl(kind, scopeId)))
.ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal));
}
///
/// The containment check, one case per kind. Each id is real and resolves to a real trie path — it
/// just belongs to another cluster, so the grant would confer authority across a cluster boundary.
/// Three of these kinds carry ClusterId directly; the other five are only reachable by walking a
/// parent chain, which is why every kind is covered rather than a representative one.
///
[Theory]
[InlineData(NodeAclScopeKind.UnsArea, "area-2")]
[InlineData(NodeAclScopeKind.UnsLine, "line-2")]
[InlineData(NodeAclScopeKind.Equipment, OtherEquipId)]
[InlineData(NodeAclScopeKind.Tag, "tag-2")]
[InlineData(NodeAclScopeKind.RawFolder, "rf-2")]
[InlineData(NodeAclScopeKind.RawDriver, "di-2")]
[InlineData(NodeAclScopeKind.RawDevice, "dev-2")]
[InlineData(NodeAclScopeKind.RawTagGroup, "tg-2")]
public void A_ScopeId_naming_an_entity_in_another_cluster_is_a_deploy_error(
NodeAclScopeKind kind, string scopeId)
{
var errors = DraftValidator.Validate(Draft(Acl(kind, scopeId)));
errors.ShouldContain(e => e.Code == "NodeAclScopeCrossCluster" && e.Context == "acl-1");
}
///
/// The trap this check exists to avoid: if the lookups were cluster-filtered, a cross-cluster id
/// would present as unresolved, the operator would be told to fix a typo in an id that is not a typo,
/// and the containment rule would never fire at all. The two codes must be mutually exclusive.
///
[Theory]
[InlineData(NodeAclScopeKind.UnsArea, "area-2")]
[InlineData(NodeAclScopeKind.UnsLine, "line-2")]
[InlineData(NodeAclScopeKind.Equipment, OtherEquipId)]
[InlineData(NodeAclScopeKind.Tag, "tag-2")]
[InlineData(NodeAclScopeKind.RawFolder, "rf-2")]
[InlineData(NodeAclScopeKind.RawDriver, "di-2")]
[InlineData(NodeAclScopeKind.RawDevice, "dev-2")]
[InlineData(NodeAclScopeKind.RawTagGroup, "tg-2")]
public void A_cross_cluster_scope_is_never_also_reported_as_unresolved(
NodeAclScopeKind kind, string scopeId)
{
var errors = DraftValidator.Validate(Draft(Acl(kind, scopeId)));
errors.ShouldNotContain(e => e.Code == "NodeAclScopeUnresolved");
errors.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal)).ShouldHaveSingleItem();
}
///
/// Gitea #533 — the gate's own identifier comparisons follow the same rule as the resolver's:
/// identifiers compare OrdinalIgnoreCase. Both were ordinal, which put the gate and the
/// runtime into disagreement in the direction that costs the most: the deploy gate would reject a
/// whole configuration as cross-cluster over a ClusterId case difference that
/// PermissionTrieBuilder — which has always matched case-insensitively — would have accepted.
///
[Fact]
public void A_case_drifted_ClusterId_is_not_reported_as_cross_cluster()
{
var acl = Acl(NodeAclScopeKind.RawDevice, "dev-1");
acl.ClusterId = Own.ToUpperInvariant();
var errors = DraftValidator.Validate(Draft(acl));
errors.ShouldNotContain(e => e.Code == "NodeAclScopeCrossCluster");
errors.ShouldNotContain(e => e.Code == "NodeAclScopeUnresolved");
}
///
/// The same rule applied to the ScopeId lookups, so a row this gate passes is a row
/// AclScopePathResolver resolves — the property the gate's whole value rests on. A gate
/// stricter than the resolver blocks deploys that would have worked; a gate looser than it lets
/// through the silently-dropped grants it exists to catch.
///
[Fact]
public void A_case_drifted_ScopeId_resolves_at_the_gate_too()
{
DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.RawDevice, "DEV-1")))
.ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal));
}
/// Negative control: relaxing case must not stop a genuinely foreign cluster from being caught.
[Fact]
public void A_genuinely_different_ClusterId_is_still_cross_cluster()
{
DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.RawDevice, "dev-2")))
.ShouldContain(e => e.Code == "NodeAclScopeCrossCluster");
}
/// The error must name both clusters — which one the grant is in and which one owns the
/// entity — because "re-author under the other cluster" and "repoint at the local entity" are different
/// fixes and the operator picks between them from this message alone.
[Fact]
public void The_cross_cluster_message_names_both_clusters()
{
var error = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.RawDevice, "dev-2")))
.First(e => e.Code == "NodeAclScopeCrossCluster");
error.Message.ShouldContain($"'{Own}'");
error.Message.ShouldContain($"'{Other}'");
}
///
/// An entity whose parent link dangles belongs to no cluster, so containment is unknowable — and it
/// resolves to no scope path either, which is the unresolved case. It must report that, once, rather
/// than a cross-cluster error built on a null owner.
///
[Fact]
public void A_device_whose_driver_is_missing_reports_unresolved_only()
{
var draft = new DraftSnapshot
{
GenerationId = 1,
ClusterId = Own,
NodeAcls = [Acl(NodeAclScopeKind.RawDevice, "dev-orphan")],
Devices =
[
new Device
{
DeviceId = "dev-orphan", DriverInstanceId = "di-gone",
Name = "orphan", DeviceConfig = "{}",
},
],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
errors.ShouldNotContain(e => e.Code == "NodeAclScopeCrossCluster");
}
/// The UNS counterpart, and a two-hop break rather than one: Equipment reaches its cluster
/// through UnsLine and only then UnsArea, so a missing line strands it just as a missing driver strands
/// a device.
[Fact]
public void An_equipment_whose_line_is_missing_reports_unresolved_only()
{
var uuid = Guid.Parse("00000000-0000-0000-0000-0000000000aa");
var draft = new DraftSnapshot
{
GenerationId = 1,
ClusterId = Own,
NodeAcls = [Acl(NodeAclScopeKind.Equipment, OwnEquipId)],
Equipment =
[
new Equipment
{
EquipmentUuid = uuid, EquipmentId = DraftValidator.DeriveEquipmentId(uuid),
Name = "filler", UnsLineId = "line-gone", MachineCode = "m",
},
],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
errors.ShouldNotContain(e => e.Code == "NodeAclScopeCrossCluster");
}
///
/// ScopeId is a bare string with no foreign key, so a real id of the wrong kind is exactly
/// the mistake that would otherwise hand a grant another entity's subtree.
///
[Fact]
public void A_real_id_of_the_wrong_kind_is_still_unresolved()
{
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.UnsArea, "dev-1")));
errors.ShouldContain(e => e.Code == "NodeAclScopeUnresolved" && e.Context == "acl-1");
}
/// Both realm tokens and both full namespace URIs are accepted, in any case — matching
/// NodeAclRealms.TryCanonicalize, which is what actually resolves the row at build time.
[Theory]
[InlineData("raw")]
[InlineData("uns")]
[InlineData("RAW")]
[InlineData("Uns")]
[InlineData(V3NodeIds.RawNamespaceUri)]
[InlineData(V3NodeIds.UnsNamespaceUri)]
[InlineData(" raw ")]
public void A_Namespace_scope_naming_a_realm_passes(string scopeId)
{
DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Namespace, scopeId)))
.ShouldNotContain(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal));
}
/// The server has exactly two namespaces; anything else names none of them.
[Theory]
[InlineData("ns=2")]
[InlineData("https://zb.com/otopcua/ns")] // the retired v2 single namespace
[InlineData("equipment")]
public void A_Namespace_scope_naming_no_realm_is_a_deploy_error(string scopeId)
{
var errors = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.Namespace, scopeId)));
errors.ShouldContain(e => e.Code == "NodeAclNamespaceInvalid" && e.Context == "acl-1");
}
///
/// FolderSegment survives in the enum only so later ordinals do not shift; it has no v3 entity and
/// can never resolve, so the error has to name the replacement rather than just say "unresolved".
///
[Fact]
public void The_retired_FolderSegment_kind_is_a_deploy_error_naming_RawFolder()
{
var error = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.FolderSegment, "some-folder")))
.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal))
.ShouldHaveSingleItem();
error.Code.ShouldBe("NodeAclScopeKindRetired");
error.Context.ShouldBe("acl-1");
error.Message.ShouldContain("RawFolder");
}
///
/// A FolderSegment row with no ScopeId is both retired and missing an id. The retirement is the
/// actionable fact — the kind can never resolve whatever its ScopeId — so telling the operator to
/// supply one would send them to fix the wrong thing.
///
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void FolderSegment_retirement_wins_over_the_missing_ScopeId_error(string? scopeId)
{
var error = DraftValidator.Validate(Draft(Acl(NodeAclScopeKind.FolderSegment, scopeId)))
.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal))
.ShouldHaveSingleItem();
error.Code.ShouldBe("NodeAclScopeKindRetired");
}
///
/// An ordinal outside the enum comes from a hand-edited row or a stale schema. Every other check
/// switches on the kind, so it must be rejected up front rather than falling into a default branch.
///
[Fact]
public void An_undefined_ScopeKind_ordinal_is_a_deploy_error()
{
var errors = DraftValidator.Validate(Draft(Acl((NodeAclScopeKind)99, "anything")));
errors.ShouldContain(e => e.Code == "NodeAclScopeKindUndefined" && e.Context == "acl-1");
}
/// Every bad row is reported in one pass — the validator's stated contract, and what lets an
/// operator fix a batch of ACLs in one edit instead of one deploy attempt each.
[Fact]
public void All_offending_rows_are_reported_in_one_pass()
{
var errors = DraftValidator.Validate(Draft(
Acl(NodeAclScopeKind.Equipment, null, "acl-a"),
Acl(NodeAclScopeKind.Cluster, OwnEquipId, "acl-b"),
Acl(NodeAclScopeKind.RawDevice, "nope", "acl-c"),
Acl(NodeAclScopeKind.RawDevice, "dev-2", "acl-d")));
errors.Where(e => e.Code.StartsWith("NodeAcl", StringComparison.Ordinal))
.Select(e => e.Context)
.ShouldBe(["acl-a", "acl-b", "acl-c", "acl-d"], ignoreOrder: true);
}
}