tests/Get-MsecEntraGroupMember.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecEntraGroupMember.
#
# The behaviour worth pinning down is where a group listing is easy to get quietly wrong:
#
# * a PIM-governed group has ELIGIBLE members who appear on NO /members endpoint. Omitting
# them makes the group read as empty while a queue of people is one activation away -
# wrong in the most dangerous direction.
# * an empty group and a mistyped name must not both be silence
# * members are not only users; filtering to users would drop the service principal somebody
# added to an access group
# * Entra display names are NOT unique, so a name matching several groups must return all of
# them rather than picking one

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

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

AfterAll {
    Remove-Module Msec -Force -ErrorAction SilentlyContinue
}

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

            # Catch-all, so an unmocked path fails the test instead of reaching the network.
            # Defined first, so the specific mocks in each It take precedence.
            Mock Invoke-MsecGraphRequest -MockWith {
                throw "unmocked Graph path in test: $Path"
            }
        }
    }

    It 'returns one row per group and member, with the group name on every row' {
        $rows = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/groups\?' } -MockWith {
                if ($Path -match 'sg-admins') {
                    [pscustomobject]@{ id = 'g1'; displayName = 'sg-admins'; securityEnabled = $true; isAssignableToRole = $true }
                } else {
                    [pscustomobject]@{ id = 'g2'; displayName = 'sg-devops'; securityEnabled = $true }
                }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/groups/g1/members' } -MockWith {
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u1'; displayName = 'Ada'; userPrincipalName = 'ada@x.com'; accountEnabled = $true; userType = 'Member' }
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u2'; displayName = 'Bob'; userPrincipalName = 'bob@x.com'; accountEnabled = $true; userType = 'Guest' }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/groups/g2/members' } -MockWith {
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u3'; displayName = 'Cleo'; userPrincipalName = 'cleo@x.com'; accountEnabled = $true }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'eligibilityScheduleInstances' } -MockWith { @() }

            Get-MsecEntraGroupMember -Name 'sg-admins', 'sg-devops'
        }

        @($rows).Count | Should -Be 3

        # The group name rides on every row - that is what makes several groups one table.
        @($rows | Where-Object GroupName -eq 'sg-admins').Count | Should -Be 2
        @($rows | Where-Object GroupName -eq 'sg-devops').Count | Should -Be 1

        $ada = $rows | Where-Object MemberName -eq 'Ada'
        $ada.MemberUserPrincipalName | Should -Be 'ada@x.com'
        $ada.MemberType              | Should -Be 'user'
        $ada.MembershipType          | Should -Be 'Active'
        $ada.GroupType               | Should -Be 'RoleAssignable'
        $ada.IsRoleAssignable        | Should -BeTrue

        ($rows | Where-Object MemberName -eq 'Bob').UserType | Should -Be 'Guest'
    }

    It 'includes PIM-eligible members, which appear on no members endpoint' {
        $rows = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/groups\?' } -MockWith {
                [pscustomobject]@{ id = 'g1'; displayName = 'sg-pim'; securityEnabled = $true }
            }
            # Nobody is an ACTUAL member - the whole membership is eligible.
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/members' } -MockWith { @() }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'eligibilityScheduleInstances' } -MockWith {
                [pscustomobject]@{ accessId = 'member'; principal = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u9'; displayName = 'Eve'; userPrincipalName = 'eve@x.com' } }
                # An OWNER does not hold what the group grants - a different finding, and
                # counting them would overstate the membership.
                [pscustomobject]@{ accessId = 'owner';  principal = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u10'; displayName = 'Owner' } }
            }

            Get-MsecEntraGroupMember -Name 'sg-pim'
        }

        # Without the eligibility read this group reads as EMPTY while someone is one
        # activation away from whatever it grants.
        @($rows).Count        | Should -Be 1
        $rows.MemberName      | Should -Be 'Eve'
        $rows.MembershipType  | Should -Be 'Eligible'
        # A real member, so the empty-group placeholder must NOT be emitted alongside it.
        $rows.MemberType      | Should -Be 'user'
    }

    It 'distinguishes an empty group from a name that matched nothing' {
        $warnings = @()
        $rows = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'sg-empty' } -MockWith {
                [pscustomobject]@{ id = 'g1'; displayName = 'sg-empty'; securityEnabled = $true }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'sg-typo' } -MockWith { @() }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/members' } -MockWith { @() }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'eligibilityScheduleInstances' } -MockWith { @() }

            Get-MsecEntraGroupMember -Name 'sg-empty', 'sg-typo'
        } -WarningVariable warnings -WarningAction SilentlyContinue

        # The empty group is accounted for with a row...
        @($rows).Count   | Should -Be 1
        $rows.GroupName  | Should -Be 'sg-empty'
        $rows.MemberType | Should -Be 'None'
        $rows.MemberName | Should -BeNullOrEmpty

        # ...and the typo is named, rather than both being silence.
        ($warnings -join ' ') | Should -Match "No group is named 'sg-typo'"
    }

    It 'keeps every group when a display name is ambiguous' {
        $warnings = @()
        $rows = InModuleScope Msec {
            # Entra display names are not unique.
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/groups\?' } -MockWith {
                [pscustomobject]@{ id = 'g1'; displayName = 'sg-dup'; securityEnabled = $true }
                [pscustomobject]@{ id = 'g2'; displayName = 'sg-dup'; securityEnabled = $true }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/groups/g1/members' } -MockWith {
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u1'; displayName = 'Ada' }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/groups/g2/members' } -MockWith {
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u2'; displayName = 'Bob' }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'eligibilityScheduleInstances' } -MockWith { @() }

            Get-MsecEntraGroupMember -Name 'sg-dup'
        } -WarningVariable warnings -WarningAction SilentlyContinue

        @($rows).Count | Should -Be 2
        @($rows | ForEach-Object { $_.GroupId } | Sort-Object) | Should -Be @('g1', 'g2')
        ($warnings -join ' ') | Should -Match '2 groups are named'
    }

    It 'reports every member type, not only users' {
        $rows = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/groups\?' } -MockWith {
                [pscustomobject]@{ id = 'g1'; displayName = 'sg-mixed'; securityEnabled = $true }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/members' } -MockWith {
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u1'; displayName = 'Ada' }
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.servicePrincipal'; id = 'sp1'; displayName = 'ci-runner' }
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.group'; id = 'g2'; displayName = 'sg-nested' }
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.device'; id = 'd1'; displayName = 'LAPTOP-01' }
                # A response that omits @odata.type: genuinely unknown, and calling it 'user'
                # would mislabel whatever it actually is.
                [pscustomobject]@{ id = 'x1'; displayName = 'Mystery' }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'eligibilityScheduleInstances' } -MockWith { @() }

            Get-MsecEntraGroupMember -Name 'sg-mixed'
        }

        @($rows).Count | Should -Be 5
        ($rows | Where-Object MemberName -eq 'ci-runner').MemberType | Should -Be 'servicePrincipal'
        ($rows | Where-Object MemberName -eq 'sg-nested').MemberType | Should -Be 'group'
        ($rows | Where-Object MemberName -eq 'LAPTOP-01').MemberType | Should -Be 'device'
        ($rows | Where-Object MemberName -eq 'Mystery').MemberType   | Should -Be 'unknown'
    }

    It 'marks a group whose membership could not be read, rather than reporting it empty' {
        $warnings = @()
        $rows = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/groups\?' } -MockWith {
                [pscustomobject]@{ id = 'g1'; displayName = 'sg-denied'; securityEnabled = $true }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/members' } -MockWith { throw 'Forbidden' }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'eligibilityScheduleInstances' } -MockWith { @() }

            Get-MsecEntraGroupMember -Name 'sg-denied'
        } -WarningVariable warnings -WarningAction SilentlyContinue

        # 'Unreadable', not 'None' - a group that could not be read must never look clean.
        @($rows).Count   | Should -Be 1
        $rows.MemberType | Should -Be 'Unreadable'
        ($warnings -join ' ') | Should -Match 'Group\.Read\.All'
    }


    It 'expands nested groups instead of listing them, at any depth' {
        $rows = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/groups\?' } -MockWith {
                [pscustomobject]@{ id = 'g1'; displayName = 'sg-top'; securityEnabled = $true }
            }
            # /transitiveMembers returns the nested groups AS WELL AS the people inside them.
            # Listing what it returns verbatim shows a group as a "member" and again as
            # everyone in it, which is the thing -Recurse exists to avoid.
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'transitiveMembers' } -MockWith {
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.group'; id = 'g2'; displayName = 'sg-nested' }
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.group'; id = 'g3'; displayName = 'sg-deeper' }
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u1'; displayName = 'Ada' }
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u2'; displayName = 'Deep' }
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.servicePrincipal'; id = 'sp1'; displayName = 'ci-runner' }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'eligibilityScheduleInstances' } -MockWith { @() }

            Get-MsecEntraGroupMember -Name 'sg-top' -Recurse
        }

        # People and service principals, no groups.
        @($rows).Count | Should -Be 3
        @($rows | ForEach-Object { $_.MemberName } | Sort-Object) | Should -Be @('Ada', 'ci-runner', 'Deep')
        @($rows | Where-Object MemberType -eq 'group').Count | Should -Be 0
        # ...and the nested group names appear nowhere at all.
        @($rows | ForEach-Object { $_.MemberName }) | Should -Not -Contain 'sg-nested'
        @($rows | ForEach-Object { $_.MemberName }) | Should -Not -Contain 'sg-deeper'
    }

    It 'reads PIM-eligible members of nested groups too when recursing' {
        $rows = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/groups\?' } -MockWith {
                [pscustomobject]@{ id = 'g1'; displayName = 'sg-top'; securityEnabled = $true }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'transitiveMembers' } -MockWith {
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.group'; id = 'g2'; displayName = 'sg-pim-nested' }
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u1'; displayName = 'Ada' }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'eligibilityScheduleInstances' } -MockWith { @() }
            # The nested group is PIM-governed, so /transitiveMembers finds nobody in it -
            # flattening only follows ACTUAL membership. Defined AFTER the catch-all: Pester
            # takes the last matching mock.
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'eligibilityScheduleInstances' -and $Path -match 'g2' } -MockWith {
                [pscustomobject]@{ accessId = 'member'; principal = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u9'; displayName = 'Eve' } }
            }

            Get-MsecEntraGroupMember -Name 'sg-top' -Recurse
        }

        # Without asking each nested group, Eve is invisible and the recursion is LESS complete
        # than not recursing at all would have been.
        @($rows | ForEach-Object { $_.MemberName } | Sort-Object) | Should -Be @('Ada', 'Eve')
        ($rows | Where-Object MemberName -eq 'Eve').MembershipType | Should -Be 'Eligible'
    }

    It 'counts a person reachable by two paths once, but keeps Active and Eligible apart' {
        $rows = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/groups\?' } -MockWith {
                [pscustomobject]@{ id = 'g1'; displayName = 'sg-top'; securityEnabled = $true }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'transitiveMembers' } -MockWith {
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.group'; id = 'g2'; displayName = 'sg-a' }
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.group'; id = 'g3'; displayName = 'sg-b' }
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u1'; displayName = 'Ada' }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'eligibilityScheduleInstances' } -MockWith { @() }
            # Ada is eligible through BOTH nested groups, and is already an active member.
            # Defined AFTER the catch-all: Pester takes the last matching mock.
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'eligibilityScheduleInstances' -and ($Path -match 'g2' -or $Path -match 'g3') } -MockWith {
                [pscustomobject]@{ accessId = 'member'; principal = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u1'; displayName = 'Ada' } }
            }

            Get-MsecEntraGroupMember -Name 'sg-top' -Recurse
        }

        # Two rows, not three: the duplicate eligible path collapses...
        @($rows).Count | Should -Be 2
        @($rows | Where-Object MembershipType -eq 'Eligible').Count | Should -Be 1
        # ...but standing membership AND an eligible assignment is a real state, kept visible.
        @($rows | Where-Object MembershipType -eq 'Active').Count | Should -Be 1
    }

    It 'still lists a nested group as a member without -Recurse' {
        InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/groups\?' } -MockWith {
                [pscustomobject]@{ id = 'g1'; displayName = 'sg-top'; securityEnabled = $true }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/members' -and $Path -notmatch 'transitive' } -MockWith {
                [pscustomobject]@{ '@odata.type' = '#microsoft.graph.group'; id = 'g2'; displayName = 'sg-nested' }
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'eligibilityScheduleInstances' } -MockWith { @() }

            # The portal's view, unchanged.
            $row = Get-MsecEntraGroupMember -Name 'sg-top'
            $row.MemberName | Should -Be 'sg-nested'
            $row.MemberType | Should -Be 'group'
        }
    }
    It 'requires something to look up' {
        InModuleScope Msec {
            { Get-MsecEntraGroupMember } | Should -Throw '*at least one*'
        }
    }

    It 'throws a clear error when not connected' {
        InModuleScope Msec {
            $script:MsecSession = $null
            { Get-MsecEntraGroupMember -Name 'x' } | Should -Throw '*Connect-Msec*'
        }
    }
}