Tests/BlastRadiusNesting.Tests.ps1

BeforeAll {
    $moduleRoot = Split-Path -Parent $PSScriptRoot
    # Import the .psm1 directly (not the manifest) so the tests run fully
    # offline, without Microsoft.Graph.Authentication installed.
    Import-Module (Join-Path $moduleRoot 'EntraGroupInsights.psm1') -Force

    $script:childId  = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
    $script:parentId = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
    $script:grandchildId = 'cccccccc-cccc-cccc-cccc-cccccccccccc'
}

Describe 'Get-EGIGroupBlastRadius (nested group detection)' {

    BeforeAll {
        Mock -ModuleName EntraGroupInsights Invoke-MgGraphRequest {
            param($Method, $Uri, $Headers)

            if ($Uri -match "^https://graph\.microsoft\.com/v1\.0/groups/$script:childId\?") {
                return [pscustomobject]@{
                    id                          = $script:childId
                    displayName                 = 'Sales-DE-Dynamic'
                    groupTypes                  = @('DynamicMembership')
                    membershipRule              = '(user.department -eq "Sales") -and (user.country -eq "DE")'
                    assignedLicenses            = @()
                    isAssignableToRole          = $false
                    resourceProvisioningOptions = @()
                }
            }
            if ($Uri -match "^https://graph\.microsoft\.com/v1\.0/groups/$script:childId/transitiveMemberOf/microsoft\.graph\.group") {
                return [pscustomobject]@{ value = @([pscustomobject]@{ id = $script:parentId; displayName = 'EU-AllStaff'; groupTypes = @() }) }
            }
            if ($Uri -match "^https://graph\.microsoft\.com/v1\.0/groups/$script:childId/transitiveMembers/microsoft\.graph\.group") {
                return [pscustomobject]@{ value = @([pscustomobject]@{ id = $script:grandchildId; displayName = 'Sales-DE-VIP'; groupTypes = @() }) }
            }
            if ($Uri -match '^https://graph\.microsoft\.com/v1\.0/identity/conditionalAccess/policies') {
                return [pscustomobject]@{
                    value = @([pscustomobject]@{
                            id          = 'pol1'
                            displayName = 'Require MFA EU'
                            state       = 'enabled'
                            conditions  = [pscustomobject]@{ users = [pscustomobject]@{ includeGroups = @($script:parentId); excludeGroups = @() } }
                        })
                }
            }
            if ($Uri -match "^https://graph\.microsoft\.com/v1\.0/groups/$script:childId/appRoleAssignments") {
                return [pscustomobject]@{ value = @() }
            }
            if ($Uri -match "^https://graph\.microsoft\.com/v1\.0/groups/$script:parentId/appRoleAssignments") {
                return [pscustomobject]@{ value = @([pscustomobject]@{ resourceDisplayName = 'Salesforce'; appRoleId = 'role-1' }) }
            }
            if ($Uri -match "^https://graph\.microsoft\.com/v1\.0/groups/$script:parentId\?") {
                return [pscustomobject]@{ assignedLicenses = @([pscustomobject]@{ skuId = 'sku-eu-123' }) }
            }
            if ($Uri -match 'groups\?\$filter=groupTypes') { return [pscustomobject]@{ value = @() } }

            throw "Unexpected mock Graph call: $Uri"
        }

        $script:blastRadius = Get-EGIGroupBlastRadius -GroupId $script:childId
    }

    It 'lists the transitive parent group' {
        $script:blastRadius.ParentGroups.displayName | Should -Contain 'EU-AllStaff'
    }

    It 'lists the transitive child group' {
        $script:blastRadius.ChildGroups.displayName | Should -Contain 'Sales-DE-VIP'
    }

    It 'attributes a Conditional Access policy on the parent group as inherited' {
        $hit = $script:blastRadius.ConditionalAccessPolicies | Where-Object DisplayName -EQ 'Require MFA EU'
        $hit | Should -Not -BeNullOrEmpty
        $hit.Source | Should -Be "Nested via 'EU-AllStaff'"
    }

    It 'attributes a license assigned to the parent group as inherited' {
        $hit = $script:blastRadius.AssignedLicenses | Where-Object SkuId -EQ 'sku-eu-123'
        $hit | Should -Not -BeNullOrEmpty
        $hit.Source | Should -Be "Nested via 'EU-AllStaff'"
    }

    It 'attributes an app role assignment on the parent group as inherited' {
        $hit = $script:blastRadius.AppRoleAssignments | Where-Object resourceDisplayName -EQ 'Salesforce'
        $hit | Should -Not -BeNullOrEmpty
        $hit.Source | Should -Be "Nested via 'EU-AllStaff'"
    }

    It 'rolls the inherited hits into TotalDependencyCount and RiskLevel' {
        $script:blastRadius.TotalDependencyCount | Should -Be 3
        $script:blastRadius.RiskLevel | Should -Be 'Critical'
    }

    It 'does not tag anything as Direct, since none of the dependencies reference the child group itself' {
        $script:blastRadius.ConditionalAccessPolicies | Where-Object Source -EQ 'Direct' | Should -BeNullOrEmpty
    }
}

Describe 'Get-EGIGroupBlastRadius (no nesting)' {

    BeforeAll {
        Mock -ModuleName EntraGroupInsights Invoke-MgGraphRequest {
            param($Method, $Uri, $Headers)

            if ($Uri -match "^https://graph\.microsoft\.com/v1\.0/groups/$script:childId\?") {
                return [pscustomobject]@{
                    id                          = $script:childId
                    displayName                 = 'Sales-DE-Dynamic'
                    groupTypes                  = @('DynamicMembership')
                    membershipRule              = '(user.department -eq "Sales")'
                    assignedLicenses            = @()
                    isAssignableToRole          = $false
                    resourceProvisioningOptions = @()
                }
            }
            if ($Uri -match 'transitiveMemberOf/microsoft\.graph\.group') { return [pscustomobject]@{ value = @() } }
            if ($Uri -match 'transitiveMembers/microsoft\.graph\.group') { return [pscustomobject]@{ value = @() } }
            if ($Uri -match 'conditionalAccess/policies') { return [pscustomobject]@{ value = @() } }
            if ($Uri -match 'appRoleAssignments') { return [pscustomobject]@{ value = @() } }
            if ($Uri -match 'groups\?\$filter=groupTypes') { return [pscustomobject]@{ value = @() } }

            throw "Unexpected mock Graph call: $Uri"
        }

        $script:blastRadius = Get-EGIGroupBlastRadius -GroupId $script:childId
    }

    It 'returns empty (not null) ParentGroups, ChildGroups, RuleReferencedGroups, and RuleReferencedByGroups arrays when the group has no nesting' {
        $script:blastRadius.ParentGroups.Count | Should -Be 0
        $script:blastRadius.ChildGroups.Count | Should -Be 0
        $script:blastRadius.RuleReferencedGroups.Count | Should -Be 0
        $script:blastRadius.RuleReferencedByGroups.Count | Should -Be 0
    }

    It 'reports RiskLevel None when nothing depends on the group directly or transitively' {
        $script:blastRadius.TotalDependencyCount | Should -Be 0
        $script:blastRadius.RiskLevel | Should -Be 'None'
    }
}

Describe 'Get-EGIGroupBlastRadius (memberOf rule references)' {

    BeforeAll {
        $script:referencedId = 'dddddddd-dddd-dddd-dddd-dddddddddddd'
    }

    Context 'the referenced group resolves' {

        BeforeAll {
            Mock -ModuleName EntraGroupInsights Invoke-MgGraphRequest {
                param($Method, $Uri, $Headers)

                if ($Uri -match "^https://graph\.microsoft\.com/v1\.0/groups/$script:childId\?") {
                    return [pscustomobject]@{
                        id                          = $script:childId
                        displayName                 = 'Contractors-AllOf-VendorX'
                        groupTypes                  = @('DynamicMembership')
                        membershipRule              = "user.memberof -any (group.objectId -in ['$script:referencedId'])"
                        assignedLicenses            = @()
                        isAssignableToRole          = $false
                        resourceProvisioningOptions = @()
                    }
                }
                if ($Uri -match 'transitiveMemberOf/microsoft\.graph\.group') { return [pscustomobject]@{ value = @() } }
                if ($Uri -match 'transitiveMembers/microsoft\.graph\.group') { return [pscustomobject]@{ value = @() } }
                if ($Uri -match 'conditionalAccess/policies') { return [pscustomobject]@{ value = @() } }
                if ($Uri -match 'appRoleAssignments') { return [pscustomobject]@{ value = @() } }
                if ($Uri -match 'groups\?\$filter=groupTypes') { return [pscustomobject]@{ value = @() } }
                if ($Uri -match "^https://graph\.microsoft\.com/v1\.0/groups/$script:referencedId\?") {
                    return [pscustomobject]@{ id = $script:referencedId; displayName = 'VendorX-Contractors' }
                }

                throw "Unexpected mock Graph call: $Uri"
            }

            $script:blastRadius = Get-EGIGroupBlastRadius -GroupId $script:childId
        }

        It 'resolves the group referenced by the memberOf clause' {
            $script:blastRadius.RuleReferencedGroups.Count | Should -Be 1
            $script:blastRadius.RuleReferencedGroups[0].id | Should -Be $script:referencedId
            $script:blastRadius.RuleReferencedGroups[0].displayName | Should -Be 'VendorX-Contractors'
        }

        It 'does not count the reference toward TotalDependencyCount or RiskLevel' {
            $script:blastRadius.TotalDependencyCount | Should -Be 0
            $script:blastRadius.RiskLevel | Should -Be 'None'
        }
    }

    Context 'the referenced group cannot be resolved (deleted / no permission)' {

        BeforeAll {
            Mock -ModuleName EntraGroupInsights Invoke-MgGraphRequest {
                param($Method, $Uri, $Headers)

                if ($Uri -match "^https://graph\.microsoft\.com/v1\.0/groups/$script:childId\?") {
                    return [pscustomobject]@{
                        id                          = $script:childId
                        displayName                 = 'Contractors-AllOf-VendorX'
                        groupTypes                  = @('DynamicMembership')
                        membershipRule              = "user.memberof -any (group.objectId -in ['$script:referencedId'])"
                        assignedLicenses            = @()
                        isAssignableToRole          = $false
                        resourceProvisioningOptions = @()
                    }
                }
                if ($Uri -match 'transitiveMemberOf/microsoft\.graph\.group') { return [pscustomobject]@{ value = @() } }
                if ($Uri -match 'transitiveMembers/microsoft\.graph\.group') { return [pscustomobject]@{ value = @() } }
                if ($Uri -match 'conditionalAccess/policies') { return [pscustomobject]@{ value = @() } }
                if ($Uri -match 'appRoleAssignments') { return [pscustomobject]@{ value = @() } }
                if ($Uri -match 'groups\?\$filter=groupTypes') { return [pscustomobject]@{ value = @() } }
                if ($Uri -match "^https://graph\.microsoft\.com/v1\.0/groups/$script:referencedId\?") {
                    throw '404 Not Found'
                }

                throw "Unexpected mock Graph call: $Uri"
            }

            $script:blastRadius = Get-EGIGroupBlastRadius -GroupId $script:childId -WarningAction SilentlyContinue
        }

        It 'still lists the referenced group by ID with an unresolved marker instead of dropping it' {
            $script:blastRadius.RuleReferencedGroups.Count | Should -Be 1
            $script:blastRadius.RuleReferencedGroups[0].id | Should -Be $script:referencedId
            $script:blastRadius.RuleReferencedGroups[0].displayName | Should -Match 'unresolved'
        }
    }
}

Describe 'Get-EGIGroupBlastRadius (reverse memberOf lookup)' {

    BeforeAll {
        $script:otherDynamicGroupId = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee'
        $script:unrelatedGroupId    = 'ffffffff-ffff-ffff-ffff-ffffffffffff'

        Mock -ModuleName EntraGroupInsights Invoke-MgGraphRequest {
            param($Method, $Uri, $Headers)

            if ($Uri -match "^https://graph\.microsoft\.com/v1\.0/groups/$script:childId\?") {
                return [pscustomobject]@{
                    id                          = $script:childId
                    displayName                 = 'VendorX-Contractors'
                    groupTypes                  = @('DynamicMembership')
                    membershipRule              = '(user.department -eq "Contractor")'
                    assignedLicenses            = @()
                    isAssignableToRole          = $false
                    resourceProvisioningOptions = @()
                }
            }
            if ($Uri -match 'transitiveMemberOf/microsoft\.graph\.group') { return [pscustomobject]@{ value = @() } }
            if ($Uri -match 'transitiveMembers/microsoft\.graph\.group') { return [pscustomobject]@{ value = @() } }
            if ($Uri -match 'conditionalAccess/policies') { return [pscustomobject]@{ value = @() } }
            if ($Uri -match "^https://graph\.microsoft\.com/v1\.0/groups/$script:childId/appRoleAssignments") { return [pscustomobject]@{ value = @() } }
            if ($Uri -match 'groups\?\$filter=groupTypes') {
                return [pscustomobject]@{
                    value = @(
                        [pscustomobject]@{ id = $script:otherDynamicGroupId; displayName = 'Contractors-AllOf-VendorX'; membershipRule = "user.memberof -any (group.objectId -in ['$script:childId'])" }
                        [pscustomobject]@{ id = $script:unrelatedGroupId; displayName = 'Some-Other-Dynamic-Group'; membershipRule = '(user.department -eq "Sales")' }
                        [pscustomobject]@{ id = $script:childId; displayName = 'VendorX-Contractors'; membershipRule = '(user.department -eq "Contractor")' }
                    )
                }
            }

            throw "Unexpected mock Graph call: $Uri"
        }

        $script:blastRadius = Get-EGIGroupBlastRadius -GroupId $script:childId
    }

    It 'finds another dynamic group whose rule references this group via memberOf' {
        $script:blastRadius.RuleReferencedByGroups.displayName | Should -Contain 'Contractors-AllOf-VendorX'
    }

    It 'excludes dynamic groups whose rule does not reference this group' {
        $script:blastRadius.RuleReferencedByGroups.displayName | Should -Not -Contain 'Some-Other-Dynamic-Group'
    }

    It 'excludes the group itself even though it appears in the tenant-wide dynamic group listing' {
        $script:blastRadius.RuleReferencedByGroups.id | Should -Not -Contain $script:childId
    }

    It 'does not count reverse references toward TotalDependencyCount or RiskLevel' {
        $script:blastRadius.TotalDependencyCount | Should -Be 0
        $script:blastRadius.RiskLevel | Should -Be 'None'
    }
}