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:
Joseph Doherty
2026-08-17 03:46:56 -04:00
parent fa9eb0c0b4
commit c79aaaf9eb
7 changed files with 389 additions and 1 deletions
@@ -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);
}
}