feat(dashboard): GroupToTag / UntaggedSessionVisibility config (SEC-25)
Groundwork for the per-session dashboard event ACL (docs/plans/2026-07-10-dashboard-session-acl-tst15.md 3.2): a dashboard group can now grant visibility tags, and untagged sessions default to AdminOnly. Enforcement lands with the EventsHub ACL; nothing consumes the grant yet. GroupToTag is deliberately uncoupled from GroupToRole - a group may appear in either map, both, or neither - and is validated for shape only. Tags gate dashboard event visibility, never data access.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DashboardGroupTagMapping"/>, the LDAP-group → dashboard
|
||||
/// visibility-tag grant. Group matching must follow the same rules as
|
||||
/// <see cref="DashboardGroupRoleMapping"/> (full DN first, leading-RDN fallback,
|
||||
/// case-insensitive), and the grant is the union across the user's groups.
|
||||
/// </summary>
|
||||
public sealed class DashboardGroupTagMappingTests
|
||||
{
|
||||
private static Dictionary<string, string[]> StandardMapping() => new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GwViewer"] = ["team-a"],
|
||||
["TeamBViewers"] = ["team-b", "team-c"],
|
||||
};
|
||||
|
||||
/// <summary>Verifies full-DN match, leading-RDN fallback, case-insensitivity, and unmapped → empty.</summary>
|
||||
/// <param name="ldapGroup">The LDAP group name or distinguished name.</param>
|
||||
/// <param name="expectedTag">The expected single granted tag, or null if no match.</param>
|
||||
[Theory]
|
||||
[InlineData("GwViewer", "team-a")]
|
||||
[InlineData("gwviewer", "team-a")]
|
||||
[InlineData("ou=GwViewer,ou=groups,dc=zb,dc=local", "team-a")]
|
||||
[InlineData("OtherGroup", null)]
|
||||
public void MapGroupsToTags_ResolvesByShortNameAndDistinguishedName(string ldapGroup, string? expectedTag)
|
||||
{
|
||||
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags([ldapGroup], StandardMapping());
|
||||
|
||||
if (expectedTag is null)
|
||||
{
|
||||
Assert.Empty(tags);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(expectedTag, Assert.Single(tags));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies the grant is the union of every matching group's tags.</summary>
|
||||
[Fact]
|
||||
public void MapGroupsToTags_MultipleGroups_UnionsTags()
|
||||
{
|
||||
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags(
|
||||
["GwViewer", "TeamBViewers"],
|
||||
StandardMapping());
|
||||
|
||||
string[] ordered = [.. tags.OrderBy(t => t, StringComparer.Ordinal)];
|
||||
Assert.Equal<string>(["team-a", "team-b", "team-c"], ordered);
|
||||
}
|
||||
|
||||
/// <summary>Verifies an unknown group contributes nothing to a grant its siblings still produce.</summary>
|
||||
[Fact]
|
||||
public void MapGroupsToTags_UnknownGroup_ContributesNothing()
|
||||
{
|
||||
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags(
|
||||
["GwViewer", "NotInTheMap"],
|
||||
StandardMapping());
|
||||
|
||||
Assert.Equal("team-a", Assert.Single(tags));
|
||||
}
|
||||
|
||||
/// <summary>Verifies the same tag granted by two groups, differing only in case, collapses to one entry.</summary>
|
||||
[Fact]
|
||||
public void MapGroupsToTags_DuplicateTagsAcrossGroups_DedupedCaseInsensitively()
|
||||
{
|
||||
Dictionary<string, string[]> mapping = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GroupOne"] = ["team-a"],
|
||||
["GroupTwo"] = ["TEAM-A"],
|
||||
};
|
||||
|
||||
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags(["GroupOne", "GroupTwo"], mapping);
|
||||
|
||||
Assert.Single(tags);
|
||||
Assert.Contains("team-a", tags);
|
||||
Assert.Contains("TEAM-A", tags);
|
||||
}
|
||||
|
||||
/// <summary>Verifies an empty map yields an empty grant — no Viewer sees a tagged session.</summary>
|
||||
[Fact]
|
||||
public void MapGroupsToTags_EmptyMapping_ReturnsNoTags()
|
||||
{
|
||||
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags(
|
||||
["GwViewer"],
|
||||
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
Assert.Empty(tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tag grant is independent of the role map: a group present only in
|
||||
/// GroupToTag still grants its tags. Asserted here because the two maps are
|
||||
/// deliberately uncoupled in validation as well.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapGroupsToTags_GroupAbsentFromRoleMap_StillGrantsTags()
|
||||
{
|
||||
Dictionary<string, string> groupToRole = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GwAdmin"] = DashboardRoles.Admin,
|
||||
};
|
||||
|
||||
IReadOnlyList<string> roles = DashboardGroupRoleMapping.MapGroupsToRoles(["TeamBViewers"], groupToRole);
|
||||
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags(["TeamBViewers"], StandardMapping());
|
||||
|
||||
string[] ordered = [.. tags.OrderBy(t => t, StringComparer.Ordinal)];
|
||||
|
||||
Assert.Empty(roles);
|
||||
Assert.Equal<string>(["team-b", "team-c"], ordered);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user