Tests/Get-MsecEntraRoleHolder.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecEntraRoleHolder. The behaviours that matter are the ones a
# partial implementation would get wrong and still look plausible: eligible
# assignments appearing at all, role-assignable groups expanding to the users inside
# them, nested groups not double-counting, AU-scoped assignments not being read as
# tenant-wide, and a tenant without Entra ID P2 degrading to a warning rather than
# either failing or silently reporting a short list.

BeforeAll {
    $modulePath = Join-Path $PSScriptRoot '..' 'msec.psm1'
    Import-Module $modulePath -Force -ErrorAction Stop

    $script:TestThumbBytes = [byte[]](1..20)

    # Every run writes the directory-roles cache for the -Role completer. Redirected so
    # the suite cannot touch the developer's real cache.
    $script:PrevCacheEnv = $env:MSEC_CACHE_DIR
    $env:MSEC_CACHE_DIR  = Join-Path ([System.IO.Path]::GetTempPath()) "msec-test-cache-$([guid]::NewGuid())"
    New-Item -ItemType Directory -Path $env:MSEC_CACHE_DIR -Force | Out-Null
    $script:CacheDir = $env:MSEC_CACHE_DIR
}

AfterAll {
    if ($script:CacheDir -and (Test-Path -LiteralPath $script:CacheDir)) {
        Remove-Item -LiteralPath $script:CacheDir -Recurse -Force
    }
    $env:MSEC_CACHE_DIR = $script:PrevCacheEnv
    Remove-Module msec -Force -ErrorAction SilentlyContinue
}

Describe 'Get-MsecEntraRoleHolder' {
    BeforeEach {
        InModuleScope msec -Parameters @{ Thumb = $script:TestThumbBytes } {
            param($Thumb)
            $script:MsecSession = @{
                TenantId        = 'tenant'
                ClientId        = 'client'
                KeyVaultName    = 'kv-test'
                KeyName         = 'msec-app'
                ThumbprintBytes = $Thumb
                Tokens          = @{}
            }
        }
    }

    It 'merges active and eligible assignments, expands groups, and flags privileged roles by template id' {
        $rows = InModuleScope msec {
            Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    # Renamed away from 'Global Administrator' to prove the privileged
                    # flag is a templateId lookup, not a display-name match.
                    [pscustomobject]@{
                        id = '62e90394-69f5-4237-9190-012177145e10'
                        templateId = '62e90394-69f5-4237-9190-012177145e10'
                        displayName = 'Tenant God Mode'; isBuiltIn = $true
                    }
                    [pscustomobject]@{
                        id = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451'
                        templateId = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451'
                        displayName = 'Global Reader'; isBuiltIn = $true
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleAssignments' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'ra1'; roleDefinitionId = '62e90394-69f5-4237-9190-012177145e10'
                        principalId = 'u1'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.user'
                            id = 'u1'; displayName = 'Anton'
                            userPrincipalName = 'anton@example.com'; accountEnabled = $true
                        }
                    }
                    # A role assigned to a role-assignable GROUP. The whole point: the
                    # users inside must surface, not just the group object.
                    [pscustomobject]@{
                        id = 'ra2'; roleDefinitionId = '62e90394-69f5-4237-9190-012177145e10'
                        principalId = 'g1'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.group'
                            id = 'g1'; displayName = 'Tier0 Admins'
                        }
                    }
                    [pscustomobject]@{
                        id = 'ra3'; roleDefinitionId = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451'
                        principalId = 'u9'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.user'
                            id = 'u9'; userPrincipalName = 'auditor@example.com'; accountEnabled = $true
                        }
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleEligibilityScheduleInstances' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 're1'; roleDefinitionId = '62e90394-69f5-4237-9190-012177145e10'
                        principalId = 'u2'; directoryScopeId = '/'
                        startDateTime = '2026-01-01T00:00:00Z'; endDateTime = $null
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.user'
                            id = 'u2'; displayName = 'Eligible Erik'
                            userPrincipalName = 'erik@example.com'; accountEnabled = $true
                        }
                    }
                ) }
            }
            # Members are fetched one type at a time via an OData cast, so the $select
            # can name user-only properties. A cast response omits @odata.type, which
            # the function must fill in from the cast it asked for.
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/groups/g1/transitiveMembers/microsoft.graph.user' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'u3'; displayName = 'Nested Nina'
                        userPrincipalName = 'nina@example.com'; accountEnabled = $true
                        userType = 'Guest'; onPremisesSyncEnabled = $null
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/groups/g1/transitiveMembers/microsoft.graph.servicePrincipal' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{ id = 'sp1'; displayName = 'break-glass-app'; accountEnabled = $true }
                ) }
            }
            # The uncast collection must not be used - it returns nested groups and a
            # property subset without userType or accountEnabled.
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/groups/g1/transitiveMembers$' } -MockWith {
                throw 'the uncast transitiveMembers collection must not be queried'
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'privilegedAccess/group/eligibilityScheduleInstances' } -MockWith {
                [pscustomobject]@{ value = @() }
            }

            Get-MsecEntraRoleHolder
        }

        # 2 direct user rows + 1 eligible + 2 expanded group members. The group itself
        # is REPLACED by its members, and the nested group row is skipped - so a group
        # of two contributes two rows, not three.
        $rows.Count | Should -Be 5

        $ga = @($rows | Where-Object RoleTemplateId -eq '62e90394-69f5-4237-9190-012177145e10')
        $ga.Count | Should -Be 4
        ($ga | Select-Object -Unique IsHighlyPrivileged).IsHighlyPrivileged | Should -BeTrue
        ($ga | Select-Object -Unique RoleName).RoleName | Should -Be 'Tenant God Mode'

        # The eligible administrator - invisible to the older /directoryRoles endpoint.
        $erik = $rows | Where-Object EffectiveId -eq 'u2'
        $erik.AssignmentType | Should -Be 'Eligible'
        $erik.MembershipType | Should -BeNullOrEmpty   # assignee and holder are one object
        $erik.EndDateTime    | Should -BeNullOrEmpty   # permanently eligible
        $erik.IsActiveNow    | Should -BeFalse         # eligibility must be activated

        # Active assignments carry no schedule, so EndDateTime must not be invented.
        $u1 = $rows | Where-Object EffectiveId -eq 'u1'
        $u1.AssignmentType | Should -Be 'Active'
        $u1.EndDateTime    | Should -BeNullOrEmpty
        $u1.IsActiveNow    | Should -BeTrue

        # On a direct assignment the assignee IS the holder - both sets of columns carry
        # the same identity rather than one of them being empty.
        $u1.PrincipalId    | Should -Be 'u1'
        $u1.PrincipalType  | Should -Be 'user'
        $u1.PrincipalName  | Should -Be 'anton@example.com'
        $u1.EffectiveName  | Should -Be 'anton@example.com'
        $u1.MembershipType | Should -BeNullOrEmpty

        # No row is left with an unknown holder: every group resolved.
        @($rows | Where-Object { -not $_.IsResolved }).Count | Should -Be 0

        $nina = $rows | Where-Object EffectiveId -eq 'u3'
        # The ASSIGNEE is the group the role is on - the object you would act on to
        # revoke the assignment - while the HOLDER is the person inside it.
        $nina.PrincipalName     | Should -Be 'Tier0 Admins'
        $nina.PrincipalType     | Should -Be 'group'
        $nina.PrincipalId       | Should -Be 'g1'
        $nina.EffectiveName     | Should -Be 'nina@example.com'
        # The cast response carried no @odata.type; the cast itself must supply it.
        $nina.EffectiveType     | Should -Be 'user'
        $nina.MembershipType    | Should -Be 'Active'
        $nina.RoleName          | Should -Be 'Tenant God Mode'
        # Detail columns describe the HOLDER, not the group it came through.
        $nina.UserPrincipalName | Should -Be 'nina@example.com'
        # A GUEST holding a privileged role through a group, and its enabled state -
        # both of which the uncast collection's property subset would have hidden.
        $nina.UserType          | Should -Be 'Guest'
        $nina.AccountEnabled    | Should -BeTrue
        $nina.IsDirectorySynced | Should -BeFalse   # Graph reports cloud-only as null

        # A service principal has no UPN - so UserPrincipalName stays null (it is
        # strictly a UPN, and the blank is how you spot an app), and DisplayName names
        # it - so a privileged application is never an anonymous row.
        $sp = $rows | Where-Object EffectiveId -eq 'sp1'
        $sp.EffectiveType     | Should -Be 'servicePrincipal'
        $sp.UserPrincipalName | Should -BeNullOrEmpty
        $sp.DisplayName       | Should -Be 'break-glass-app'
        # ...and EffectiveName falls back to that name, so the pair (type, name)
        # identifies an app as well as it identifies a person.
        $sp.EffectiveName     | Should -Be 'break-glass-app'
        $sp.IsResolved        | Should -BeTrue
        # UserType and IsDirectorySynced are user-only facts; inventing them for an
        # app would put 'Member' next to something that has no account type at all.
        $sp.UserType          | Should -BeNullOrEmpty
        $sp.IsDirectorySynced | Should -BeNullOrEmpty

        # The group is never its own holder when it resolved - counting both the group
        # and its members would inflate every report built on this.
        $rows | Where-Object EffectiveId -eq 'g1' | Should -BeNullOrEmpty
        # The nested group is structure, not a principal.
        $rows | Where-Object EffectiveId -eq 'g2' | Should -BeNullOrEmpty

        # Global Reader is read-only and deliberately not privileged.
        ($rows | Where-Object EffectiveId -eq 'u9').IsHighlyPrivileged | Should -BeFalse

        $rows[0].PSObject.TypeNames | Should -Contain 'MsecEntraRoleHolder'
    }

    It 'projects administrative-unit scope and does not read it as tenant-wide' {
        $rows = InModuleScope msec {
            Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'fe930be7-5e62-47db-91af-98c3a49a38b1'
                        templateId = 'fe930be7-5e62-47db-91af-98c3a49a38b1'
                        displayName = 'User Administrator'
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleAssignments' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'ra1'; roleDefinitionId = 'fe930be7-5e62-47db-91af-98c3a49a38b1'
                        principalId = 'u1'; directoryScopeId = '/administrativeUnits/au-guid'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.user'
                            id = 'u1'; userPrincipalName = 'scoped@example.com'
                        }
                    }
                    [pscustomobject]@{
                        id = 'ra2'; roleDefinitionId = 'fe930be7-5e62-47db-91af-98c3a49a38b1'
                        principalId = 'u2'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.user'
                            id = 'u2'; userPrincipalName = 'tenantwide@example.com'
                        }
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleEligibilityScheduleInstances' } -MockWith {
                [pscustomobject]@{ value = @() }
            }

            Get-MsecEntraRoleHolder
        }

        $scoped = $rows | Where-Object PrincipalId -eq 'u1'
        $scoped.Scope            | Should -Be 'AdministrativeUnit:au-guid'
        $scoped.IsTenantScoped   | Should -BeFalse
        $scoped.DirectoryScopeId | Should -Be '/administrativeUnits/au-guid'

        $wide = $rows | Where-Object PrincipalId -eq 'u2'
        $wide.Scope          | Should -Be 'Tenant'
        $wide.IsTenantScoped | Should -BeTrue
    }

    It 'warns about the missing licence and still returns active assignments when the tenant has no Entra ID P2' {
        $result = InModuleScope msec {
            Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = '62e90394-69f5-4237-9190-012177145e10'
                        templateId = '62e90394-69f5-4237-9190-012177145e10'
                        displayName = 'Global Administrator'
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleAssignments' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'ra1'; roleDefinitionId = '62e90394-69f5-4237-9190-012177145e10'
                        principalId = 'u1'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.user'
                            id = 'u1'; userPrincipalName = 'anton@example.com'
                        }
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleEligibilityScheduleInstances' } -MockWith {
                $err = [System.Management.Automation.ErrorRecord]::new(
                    [System.Exception]::new('Response status code does not indicate success: 403 (Forbidden).'),
                    'GraphError', 'InvalidOperation', $null)
                throw $err
            }

            # The 403 body is what tells licensing apart from permission; without
            # ErrorDetails the function must fall back to the permission wording.
            $warnings = @()
            $rows = Get-MsecEntraRoleHolder -WarningVariable warnings -WarningAction SilentlyContinue
            [pscustomobject]@{ Rows = @($rows); Warnings = @($warnings) }
        }

        # Active assignments survive - a failed eligibility read must not sink them.
        $result.Rows.Count       | Should -Be 1
        $result.Rows[0].RoleName | Should -Be 'Global Administrator'

        # And the incompleteness is stated, not swallowed.
        $result.Warnings.Count | Should -BeGreaterThan 0
        ($result.Warnings -join ' ') | Should -Match 'INCOMPLETE|licence'
    }

    It 'skips the eligibility call entirely with -AssignmentType Active' {
        $rows = InModuleScope msec {
            Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = '62e90394-69f5-4237-9190-012177145e10'
                        templateId = '62e90394-69f5-4237-9190-012177145e10'
                        displayName = 'Global Administrator'
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleAssignments' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'ra1'; roleDefinitionId = '62e90394-69f5-4237-9190-012177145e10'
                        principalId = 'u1'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.user'
                            id = 'u1'; userPrincipalName = 'anton@example.com'
                        }
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleEligibilityScheduleInstances' } -MockWith {
                throw 'eligibility must not be queried when -AssignmentType Active'
            }

            Get-MsecEntraRoleHolder -AssignmentType Active
        }

        $rows.Count              | Should -Be 1
        $rows[0].AssignmentType  | Should -Be 'Active'
    }

    It 'fetches a role definition the collection omits rather than reporting a bare GUID' {
        $rows = InModuleScope msec {
            Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
            }
            # The collection knows nothing about the assigned definition...
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleDefinitions\?' } -MockWith {
                [pscustomobject]@{ value = @() }
            }
            # ...but a direct read of it succeeds, as it does for hidden built-ins.
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleDefinitions/11111111' } -MockWith {
                [pscustomobject]@{
                    id = '11111111-2222-3333-4444-555555555555'
                    templateId = '11111111-2222-3333-4444-555555555555'
                    displayName = 'Some Hidden Built-In'; isBuiltIn = $true
                }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleAssignments' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'ra1'; roleDefinitionId = '11111111-2222-3333-4444-555555555555'
                        principalId = 'u1'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.user'
                            id = 'u1'; userPrincipalName = 'anton@example.com'
                        }
                    }
                    # A second assignment of the same definition must not re-fetch it;
                    # if it did, the mock below would still answer, so assert the count.
                    [pscustomobject]@{
                        id = 'ra2'; roleDefinitionId = '11111111-2222-3333-4444-555555555555'
                        principalId = 'u2'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.user'
                            id = 'u2'; userPrincipalName = 'sam@example.com'
                        }
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleEligibilityScheduleInstances' } -MockWith {
                [pscustomobject]@{ value = @() }
            }

            $result = @(Get-MsecEntraRoleHolder)
            Should -Invoke Invoke-RestMethod -Times 1 -Exactly `
                -ParameterFilter { $Uri -match 'roleDefinitions/11111111' }
            $result
        }

        $rows.Count | Should -Be 2
        ($rows | Select-Object -Unique RoleName).RoleName | Should -Be 'Some Hidden Built-In'
    }

    It 'flags and counts principals Graph returns as id-only shells instead of emitting blank rows' {
        $result = InModuleScope msec {
            Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = '62e90394-69f5-4237-9190-012177145e10'
                        templateId = '62e90394-69f5-4237-9190-012177145e10'
                        displayName = 'Global Administrator'
                    }
                ) }
            }
            # Exactly what Graph returns when the app may read role assignments but not
            # the directory objects behind them: the full property schema, all null.
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleAssignments' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'ra1'; roleDefinitionId = '62e90394-69f5-4237-9190-012177145e10'
                        principalId = 'u1'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.user'
                            id = 'u1'; displayName = $null
                            userPrincipalName = $null; accountEnabled = $null
                        }
                    }
                    [pscustomobject]@{
                        id = 'ra2'; roleDefinitionId = '62e90394-69f5-4237-9190-012177145e10'
                        principalId = 'sp1'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.servicePrincipal'
                            id = 'sp1'; displayName = $null
                        }
                    }
                    # A readable one, to prove the flag is per-row and not global.
                    [pscustomobject]@{
                        id = 'ra3'; roleDefinitionId = '62e90394-69f5-4237-9190-012177145e10'
                        principalId = 'u2'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.user'
                            id = 'u2'; displayName = 'Anton'
                            userPrincipalName = 'anton@example.com'; accountEnabled = $true
                        }
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleEligibilityScheduleInstances' } -MockWith {
                [pscustomobject]@{ value = @() }
            }

            $warnings = @()
            $rows = Get-MsecEntraRoleHolder -WarningVariable warnings -WarningAction SilentlyContinue
            [pscustomobject]@{ Rows = @($rows); Warnings = @($warnings) }
        }

        # Nothing is dropped - the assignments are real even when the identities are opaque.
        $result.Rows.Count | Should -Be 3

        # An unnameable administrator reads as an unknown, not as a blank cell.
        ($result.Rows | Where-Object PrincipalId -eq 'u1').IsResolved  | Should -BeFalse
        ($result.Rows | Where-Object PrincipalId -eq 'sp1').IsResolved | Should -BeFalse
        ($result.Rows | Where-Object PrincipalId -eq 'u2').IsResolved  | Should -BeTrue
        # IsActiveNow must not claim an unknown identity can use the role.
        ($result.Rows | Where-Object PrincipalId -eq 'u1').IsActiveNow | Should -BeNullOrEmpty
        ($result.Rows | Where-Object PrincipalId -eq 'u2').IsActiveNow | Should -BeTrue

        # And the caller is told, with a count and the permission to grant.
        ($result.Warnings -join ' ') | Should -Match '2 privileged principal'
        ($result.Warnings -join ' ') | Should -Match 'User.Read.All'
    }

    It 'expands a PIM-governed group with no active members into its ELIGIBLE members' {
        # The real-tenant case that motivated this: a group named for the role it
        # carries, with an empty membership and a queue of people eligible to activate
        # into it. /transitiveMembers reports nothing, so without the PIM-for-Groups
        # endpoint the tenant looks like it has nobody in the role.
        $rows = InModuleScope msec {
            Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451'
                        templateId = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451'
                        displayName = 'Global Reader'
                    }
                ) }
            }
            # The group is ACTIVELY assigned the role...
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleAssignments' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'ra1'; roleDefinitionId = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451'
                        principalId = 'g-pim'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.group'
                            id = 'g-pim'; displayName = 'pim-group-global-reader'
                        }
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleEligibilityScheduleInstances' } -MockWith {
                [pscustomobject]@{ value = @() }
            }
            # ...and has no actual members at all.
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'transitiveMembers' } -MockWith {
                [pscustomobject]@{ value = @() }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'privilegedAccess/group/eligibilityScheduleInstances' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'g-pim_member_1'; accessId = 'member'; groupId = 'g-pim'
                        principalId = 'u1'; memberType = 'Direct'; endDateTime = $null
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.user'
                            id = 'u1'; displayName = 'Pim-Eligible Pia'
                            userPrincipalName = 'pia@example.com'
                            accountEnabled = $true; userType = 'Member'
                        }
                    }
                    # An OWNER can add themselves as a member and only then hold the
                    # role, so counting them here would overstate the population.
                    [pscustomobject]@{
                        id = 'g-pim_owner_2'; accessId = 'owner'; groupId = 'g-pim'
                        principalId = 'u2'; memberType = 'Direct'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.user'
                            id = 'u2'; userPrincipalName = 'owner@example.com'
                        }
                    }
                ) }
            }

            Get-MsecEntraRoleHolder
        }

        # The eligible member replaces the group, exactly as an active member would.
        $rows.Count              | Should -Be 1
        $rows[0].EffectiveName   | Should -Be 'pia@example.com'
        $rows[0].EffectiveType   | Should -Be 'user'
        $rows[0].RoleName        | Should -Be 'Global Reader'
        $rows[0].PrincipalName   | Should -Be 'pim-group-global-reader'
        $rows[0].PrincipalType   | Should -Be 'group'
        $rows[0].MembershipType  | Should -Be 'Eligible'
        $rows[0].IsResolved      | Should -BeTrue

        # Each link keeps its own truth: the ASSIGNMENT to the group is active, the
        # MEMBERSHIP is not. Folding them into one column would have to lie about one.
        $rows[0].AssignmentType  | Should -Be 'Active'
        # And the conjunction is the answer to "can they use it right now" - no.
        $rows[0].IsActiveNow     | Should -BeFalse

        # The group has a holder now, so it is not its own row.
        $rows | Where-Object EffectiveType -eq 'group' | Should -BeNullOrEmpty
        # And the owner is not a role holder.
        $rows | Where-Object EffectiveId -eq 'u2' | Should -BeNullOrEmpty
    }

    It 'keeps a role assigned to an EMPTY group as an unresolved row rather than dropping the assignment' {
        $result = InModuleScope msec {
            Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = '62e90394-69f5-4237-9190-012177145e10'
                        templateId = '62e90394-69f5-4237-9190-012177145e10'
                        displayName = 'Global Administrator'
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleAssignments' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'ra1'; roleDefinitionId = '62e90394-69f5-4237-9190-012177145e10'
                        principalId = 'g1'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.group'
                            id = 'g1'; displayName = 'Empty Tier0 group'
                        }
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleEligibilityScheduleInstances' } -MockWith {
                [pscustomobject]@{ value = @() }
            }
            # A group holding only an empty nested group: the per-type casts return no
            # user and no service principal, and PIM knows no eligible members either,
            # so the assignment has nobody at all to stand for.
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'transitiveMembers' } -MockWith {
                [pscustomobject]@{ value = @() }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'eligibilityScheduleInstances' -and $Uri -match 'groupId' } -MockWith {
                [pscustomobject]@{ value = @() }
            }

            $warnings = @()
            $rows = Get-MsecEntraRoleHolder -WarningVariable warnings -WarningAction SilentlyContinue
            [pscustomobject]@{ Rows = @($rows); Warnings = @($warnings) }
        }

        # Global Administrator is assigned to a group someone could populate tomorrow.
        # That must not vanish from a privileged-access report. The assignee is known;
        # the holder is not, which is exactly what the row says.
        $result.Rows.Count | Should -Be 1
        $row = $result.Rows[0]
        $row.PrincipalId   | Should -Be 'g1'
        $row.PrincipalType | Should -Be 'group'
        $row.PrincipalName | Should -Be 'Empty Tier0 group'
        $row.EffectiveId   | Should -BeNullOrEmpty
        $row.EffectiveName | Should -BeNullOrEmpty
        $row.EffectiveType | Should -BeNullOrEmpty
        $row.IsResolved    | Should -BeFalse
        $row.IsActiveNow   | Should -BeNullOrEmpty
        $row.RoleName      | Should -Be 'Global Administrator'

        # A blank EffectiveName in a table reads as a broken report, so the run must
        # account for the blanks rather than leave them to be interpreted - and must
        # name the permission that is the usual cause.
        $joined = $result.Warnings -join ' '
        $joined | Should -Match 'UNKNOWN HOLDER'
        $joined | Should -Match 'PrivilegedEligibilitySchedule.Read.AzureADGroup'
    }

    It 'leaves groups unexpanded with -NoGroupExpansion' {
        $rows = InModuleScope msec {
            Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = '62e90394-69f5-4237-9190-012177145e10'
                        templateId = '62e90394-69f5-4237-9190-012177145e10'
                        displayName = 'Global Administrator'
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleAssignments' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'ra1'; roleDefinitionId = '62e90394-69f5-4237-9190-012177145e10'
                        principalId = 'g1'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.group'
                            id = 'g1'; displayName = 'Tier0 Admins'
                        }
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleEligibilityScheduleInstances' } -MockWith {
                [pscustomobject]@{ value = @() }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'transitiveMembers' } -MockWith {
                throw 'group expansion must not happen with -NoGroupExpansion'
            }

            Get-MsecEntraRoleHolder -NoGroupExpansion
        }

        # The assignment-level view: assignee named, holders deliberately not looked up.
        $rows.Count            | Should -Be 1
        $rows[0].PrincipalType | Should -Be 'group'
        $rows[0].PrincipalName | Should -Be 'Tier0 Admins'
        $rows[0].EffectiveName | Should -BeNullOrEmpty
        $rows[0].IsResolved    | Should -BeFalse
    }

    It 'warns and keeps the group row when the group cannot be expanded' {
        $result = InModuleScope msec {
            Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = '62e90394-69f5-4237-9190-012177145e10'
                        templateId = '62e90394-69f5-4237-9190-012177145e10'
                        displayName = 'Global Administrator'
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleAssignments' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'ra1'; roleDefinitionId = '62e90394-69f5-4237-9190-012177145e10'
                        principalId = 'g1'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.group'
                            id = 'g1'; displayName = 'Tier0 Admins'
                        }
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleEligibilityScheduleInstances' } -MockWith {
                [pscustomobject]@{ value = @() }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'transitiveMembers' } -MockWith {
                throw 'Response status code does not indicate success: 403 (Forbidden).'
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'eligibilityScheduleInstances' -and $Uri -match 'groupId' } -MockWith {
                [pscustomobject]@{ value = @() }
            }

            $warnings = @()
            $rows = Get-MsecEntraRoleHolder -WarningVariable warnings -WarningAction SilentlyContinue
            [pscustomobject]@{ Rows = @($rows); Warnings = @($warnings) }
        }

        # An unreadable group is an unknown, not a zero - the assignment stays visible.
        $result.Rows.Count             | Should -Be 1
        $result.Rows[0].PrincipalType  | Should -Be 'group'
        $result.Rows[0].IsResolved     | Should -BeFalse
        ($result.Warnings -join ' ')   | Should -Match 'Group.Read.All'
    }

    It 'falls back to per-role-definition filtering when Graph rejects the unfiltered list' {
        $rows = InModuleScope msec {
            Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = '62e90394-69f5-4237-9190-012177145e10'
                        templateId = '62e90394-69f5-4237-9190-012177145e10'
                        displayName = 'Global Administrator'
                    }
                ) }
            }
            # The filtered retry (URI carries an escaped $filter) succeeds...
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleAssignments' -and $Uri -match 'roleDefinitionId' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        id = 'ra1'; roleDefinitionId = '62e90394-69f5-4237-9190-012177145e10'
                        principalId = 'u1'; directoryScopeId = '/'
                        principal = [pscustomobject]@{
                            '@odata.type' = '#microsoft.graph.user'
                            id = 'u1'; userPrincipalName = 'anton@example.com'
                        }
                    }
                ) }
            }
            # ...while the unfiltered list is rejected the way Graph rejects it.
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleAssignments' -and $Uri -notmatch 'roleDefinitionId' } -MockWith {
                throw 'A $filter query parameter is required for this request.'
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleEligibilityScheduleInstances' } -MockWith {
                [pscustomobject]@{ value = @() }
            }

            Get-MsecEntraRoleHolder -AssignmentType Active
        }

        $rows.Count                 | Should -Be 1
        $rows[0].UserPrincipalName  | Should -Be 'anton@example.com'
    }

    It 'rewrites a 403 on roleDefinitions to name the missing permission' {
        InModuleScope msec {
            Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'roleDefinitions' } -MockWith {
                throw 'Response status code does not indicate success: 403 (Forbidden).'
            }

            { Get-MsecEntraRoleHolder } |
                Should -Throw -ExpectedMessage '*RoleManagement.Read.Directory*'
        }
    }

    Context '-Role' {
        # The tenant used throughout this context. Note the Global Administrator
        # definition is reported by its LEGACY display name, which is what Graph is
        # observed to return - so every "by name" case here is also a test that the
        # canonical map is doing its job.
        BeforeEach {
            InModuleScope msec -Parameters @{ Thumb = $script:TestThumbBytes } {
                param($Thumb)
                $script:MsecSession = @{
                    TenantId        = 'tenant'
                    ClientId        = 'client'
                    KeyVaultName    = 'kv-test'
                    KeyName         = 'msec-app'
                    ThumbprintBytes = $Thumb
                    Tokens          = @{}
                }
            }
        }

        BeforeAll {
            $script:RoleMocks = {
                Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
                Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                    [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
                }
                Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = @{ Id = 'tenant-role-tests' } } }

                Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleDefinitions' } -MockWith {
                    [pscustomobject]@{ value = @(
                        [pscustomobject]@{ id = 'def-ga'; displayName = 'Company Administrator'
                                           templateId = '62e90394-69f5-4237-9190-012177145e10'; isBuiltIn = $true }
                        [pscustomobject]@{ id = 'def-ua'; displayName = 'User Administrator'
                                           templateId = 'fe930be7-5e62-47db-91af-98c3a49a38b1'; isBuiltIn = $true }
                        [pscustomobject]@{ id = 'def-reader'; displayName = 'Global Reader'
                                           templateId = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451'; isBuiltIn = $true }
                        # Custom role: no templateId, so only its id or display name can name it.
                        [pscustomobject]@{ id = 'def-custom'; displayName = 'Contoso Vault Reader'
                                           templateId = $null; isBuiltIn = $false }
                    ) }
                }

                # Honours the $filter the command sends, so a test can tell server-side
                # filtering from a client-side Where-Object.
                #
                # Matched against the FULLY DECODED uri. Invoke-RestMethod's -Uri is typed
                # [System.Uri], so PowerShell coerces the string the command built and
                # .NET's canonicalisation unescapes %20 back to a literal space while
                # leaving %27 alone - the mock would never see the bytes that were sent.
                # Decoding both sides sidesteps the whole question.
                Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleAssignments' } -MockWith {
                    $decoded = [uri]::UnescapeDataString([string]$Uri)
                    $script:RequestedUris.Add($decoded)

                    $mk = {
                        param($AssignmentId, $DefinitionId, $PrincipalId)
                        $p = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'
                                                id = $PrincipalId; userPrincipalName = "$PrincipalId@x.com" }
                        [pscustomobject]@{ id = $AssignmentId; roleDefinitionId = $DefinitionId
                                           principalId = $PrincipalId; directoryScopeId = '/'; principal = $p }
                    }
                    $all = @(
                        & $mk 'as1' 'def-ga'     'ga1'
                        & $mk 'as2' 'def-ga'     'ga2'
                        & $mk 'as3' 'def-ua'     'ua1'
                        & $mk 'as4' 'def-reader' 'rd1'
                        & $mk 'as5' 'def-custom' 'cu1'
                    )

                    if ($decoded -match "roleDefinitionId eq '(?<id>[^']+)'") {
                        $wanted = $Matches['id']
                        return [pscustomobject]@{ value = @($all | Where-Object roleDefinitionId -eq $wanted) }
                    }
                    [pscustomobject]@{ value = $all }
                }

                Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleEligibilityScheduleInstances' } -MockWith {
                    [pscustomobject]@{ value = @() }
                }
            }

            # Passed as TEXT and rebuilt with [scriptblock]::Create inside InModuleScope.
            # A scriptblock stays bound to the session state it was written in, so calling
            # this one directly would fail to resolve Mock's module-private targets - the
            # mocks would silently not apply.
            $script:RoleMockText = $script:RoleMocks.ToString()
        }

        It 'resolves a canonical name against a directory that uses the legacy name' {
            # THE case this parameter exists for. The tenant calls the role 'Company
            # Administrator'; nobody types that. Matching display names only would return
            # nothing here and read as "no Global Admins".
            $rows = InModuleScope msec -Parameters @{ MockText = $script:RoleMockText } {
                param($MockText)
                $script:RequestedUris = [System.Collections.Generic.List[string]]::new()
                & ([scriptblock]::Create($MockText))
                Get-MsecEntraRoleHolder -Role 'Global Administrator' -WarningAction SilentlyContinue
            }

            @($rows).Count | Should -Be 2
            @($rows.EffectiveId | Sort-Object) | Should -Be @('ga1', 'ga2')
            ($rows | Select-Object -Unique RoleName).RoleName | Should -Be 'Company Administrator'
        }

        It 'is case-insensitive and tolerates surrounding whitespace' {
            $rows = InModuleScope msec -Parameters @{ MockText = $script:RoleMockText } {
                param($MockText)
                $script:RequestedUris = [System.Collections.Generic.List[string]]::new()
                & ([scriptblock]::Create($MockText))
                Get-MsecEntraRoleHolder -Role ' global ADMINISTRATOR ' -WarningAction SilentlyContinue
            }
            @($rows).Count | Should -Be 2
        }

        It 'resolves a roleTemplateId' {
            $rows = InModuleScope msec -Parameters @{ MockText = $script:RoleMockText } {
                param($MockText)
                $script:RequestedUris = [System.Collections.Generic.List[string]]::new()
                & ([scriptblock]::Create($MockText))
                Get-MsecEntraRoleHolder -Role '62e90394-69f5-4237-9190-012177145e10' -WarningAction SilentlyContinue
            }
            @($rows).Count | Should -Be 2
        }

        It 'resolves the display name this tenant actually reports' {
            $rows = InModuleScope msec -Parameters @{ MockText = $script:RoleMockText } {
                param($MockText)
                $script:RequestedUris = [System.Collections.Generic.List[string]]::new()
                & ([scriptblock]::Create($MockText))
                Get-MsecEntraRoleHolder -Role 'Company Administrator' -WarningAction SilentlyContinue
            }
            @($rows).Count | Should -Be 2
        }

        It 'resolves a custom role by its definition id, which has no template id' {
            $rows = InModuleScope msec -Parameters @{ MockText = $script:RoleMockText } {
                param($MockText)
                $script:RequestedUris = [System.Collections.Generic.List[string]]::new()
                & ([scriptblock]::Create($MockText))
                Get-MsecEntraRoleHolder -Role 'def-custom' -WarningAction SilentlyContinue
            }

            @($rows).Count            | Should -Be 1
            $rows.EffectiveId         | Should -Be 'cu1'
            $rows.RoleName            | Should -Be 'Contoso Vault Reader'
            # Custom roles are never flagged privileged - the flag is a built-in lookup.
            $rows.IsHighlyPrivileged  | Should -BeFalse
        }

        It 'accepts several roles, mixing names and template ids' {
            $rows = InModuleScope msec -Parameters @{ MockText = $script:RoleMockText } {
                param($MockText)
                $script:RequestedUris = [System.Collections.Generic.List[string]]::new()
                & ([scriptblock]::Create($MockText))
                Get-MsecEntraRoleHolder -WarningAction SilentlyContinue `
                    -Role 'Global Administrator', 'fe930be7-5e62-47db-91af-98c3a49a38b1', 'Contoso Vault Reader'
            }

            @($rows).Count | Should -Be 4
            @($rows.RoleName | Sort-Object -Unique) | Should -Be @('Company Administrator', 'Contoso Vault Reader', 'User Administrator')
            # Global Reader was not asked for and must not appear.
            $rows.EffectiveId | Should -Not -Contain 'rd1'
        }

        It 'filters server-side: one request per named role, and no full-tenant sweep' {
            # Wrapped in an object because InModuleScope unrolls a single-element
            # collection to the string inside it, and $uris[0] would then index a
            # character rather than a uri.
            $out = InModuleScope msec -Parameters @{ MockText = $script:RoleMockText } {
                param($MockText)
                $script:RequestedUris = [System.Collections.Generic.List[string]]::new()
                & ([scriptblock]::Create($MockText))
                Get-MsecEntraRoleHolder -Role 'Global Administrator' -AssignmentType Active -WarningAction SilentlyContinue | Out-Null
                [pscustomobject]@{ Uris = @($script:RequestedUris) }
            }

            @($out.Uris).Count | Should -Be 1 -Because 'one named role is one filtered request'
            $out.Uris[0]       | Should -Match "roleDefinitionId eq 'def-ga'"
            # The point of the parameter: never pull every assignment in the tenant and
            # discard the rest.
            @($out.Uris | Where-Object { $_ -notmatch '\$filter=' }).Count | Should -Be 0
        }

        It 'sends one filtered request per role when several are named' {
            $out = InModuleScope msec -Parameters @{ MockText = $script:RoleMockText } {
                param($MockText)
                $script:RequestedUris = [System.Collections.Generic.List[string]]::new()
                & ([scriptblock]::Create($MockText))
                Get-MsecEntraRoleHolder -AssignmentType Active -WarningAction SilentlyContinue `
                    -Role 'Global Administrator', 'User Administrator' | Out-Null
                [pscustomobject]@{ Uris = @($script:RequestedUris) }
            }

            @($out.Uris).Count | Should -Be 2
            ($out.Uris -join ' ') | Should -Match "roleDefinitionId eq 'def-ga'"
            ($out.Uris -join ' ') | Should -Match "roleDefinitionId eq 'def-ua'"
            # Global Reader was never asked for, so it was never fetched.
            ($out.Uris -join ' ') | Should -Not -Match 'def-reader'
        }

        It 'reads every assignment in one request when -Role is omitted' {
            # The unfiltered path must survive: it is one round trip for the whole tenant,
            # which is the right shape when you want the whole tenant.
            $out = InModuleScope msec -Parameters @{ MockText = $script:RoleMockText } {
                param($MockText)
                $script:RequestedUris = [System.Collections.Generic.List[string]]::new()
                & ([scriptblock]::Create($MockText))
                Get-MsecEntraRoleHolder -AssignmentType Active -WarningAction SilentlyContinue | Out-Null
                [pscustomobject]@{ Uris = @($script:RequestedUris) }
            }

            @($out.Uris).Count | Should -Be 1
            $out.Uris[0]       | Should -Not -Match '\$filter='
        }

        It 'throws on an unrecognised role and names the tenant''s roles' {
            # A typo that returned zero rows would read as a clean bill of health.
            InModuleScope msec -Parameters @{ MockText = $script:RoleMockText } {
                param($MockText)
                $script:RequestedUris = [System.Collections.Generic.List[string]]::new()
                & ([scriptblock]::Create($MockText))
                { Get-MsecEntraRoleHolder -Role 'Globl Administrator' } |
                    Should -Throw -ExpectedMessage '*Unrecognised role*'
            }
        }

        It 'lists the available roles in the error, so the fix is in the message' {
            $message = InModuleScope msec -Parameters @{ MockText = $script:RoleMockText } {
                param($MockText)
                $script:RequestedUris = [System.Collections.Generic.List[string]]::new()
                & ([scriptblock]::Create($MockText))
                try { Get-MsecEntraRoleHolder -Role 'Nonsense Administrator'; '' }
                catch { $_.Exception.Message }
            }

            $message | Should -Match 'Nonsense Administrator'
            $message | Should -Match 'Company Administrator'
            $message | Should -Match 'Contoso Vault Reader'
        }

        It 'fails the whole call when only one of several roles is unrecognised' {
            # Returning the roles that DID resolve would hand back a partial answer that
            # looks complete - worse than failing, for an access review.
            InModuleScope msec -Parameters @{ MockText = $script:RoleMockText } {
                param($MockText)
                $script:RequestedUris = [System.Collections.Generic.List[string]]::new()
                & ([scriptblock]::Create($MockText))
                { Get-MsecEntraRoleHolder -Role 'Global Administrator', 'Not A Role' } |
                    Should -Throw -ExpectedMessage '*Not A Role*'
            }
        }

        It 'returns nothing, without error, for a known role absent from this tenant' {
            # Distinct from a typo: msec knows this name, the tenant has no definition for
            # it, so nobody holds it. An empty answer is the correct answer.
            $rows = InModuleScope msec {
                Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
                Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                    [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
                }
                Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = @{ Id = 'tenant-role-tests' } } }
                Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleDefinitions' } -MockWith {
                    [pscustomobject]@{ value = @(
                        [pscustomobject]@{ id = 'def-ga'; displayName = 'Company Administrator'
                                           templateId = '62e90394-69f5-4237-9190-012177145e10'; isBuiltIn = $true }
                    ) }
                }
                Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleAssignments' } -MockWith {
                    throw 'no assignment call should be made for a role with no definition'
                }

                @(Get-MsecEntraRoleHolder -Role 'Partner Tier2 Support' -WarningAction SilentlyContinue)
            }

            @($rows).Count | Should -Be 0
        }

        It 'refuses -Role together with -HighlyPrivilegedOnly' {
            # Logically empty combinations are another way to get a zero that reads as an
            # answer, so the parameter sets make it unrepresentable.
            { Get-MsecEntraRoleHolder -Role 'Global Administrator' -HighlyPrivilegedOnly } |
                Should -Throw
        }

        It 'caches the tenant''s role definitions for the completer' {
            $cached = InModuleScope msec -Parameters @{ MockText = $script:RoleMockText } {
                param($MockText)
                $script:RequestedUris = [System.Collections.Generic.List[string]]::new()
                & ([scriptblock]::Create($MockText))
                Get-MsecEntraRoleHolder -AssignmentType Active -WarningAction SilentlyContinue | Out-Null
                @(Read-MsecCache -Name 'directory-roles')
            }

            @($cached).Count | Should -Be 4
            # Both names are kept: the completer needs the canonical one to offer and the
            # directory one to show alongside it.
            ($cached | Where-Object TemplateId -eq '62e90394-69f5-4237-9190-012177145e10').DisplayName |
                Should -Be 'Company Administrator'
            ($cached | Where-Object Id -eq 'def-custom').IsBuiltIn | Should -BeFalse
        }
    }
}