Tests/Get-MsecEntraTenantSecuritySetting.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecEntraTenantSecuritySetting. The behaviour that matters most is
# the one this cmdlet exists for: telling "the tenant isn't licensed for this"
# apart from "we couldn't read it". A read that succeeds and finds no premium
# licence must report ConditionalAccessAvailable = $false (a real answer); a read
# that FAILS must report $null plus a reason in Notes - never $false, which would
# assert something unmeasured.

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-MsecEntraTenantSecuritySetting' {
    BeforeEach {
        InModuleScope Msec -Parameters @{ Thumb = $script:TestThumbBytes } {
            param($Thumb)
            $script:MsecSession = @{
                TenantId        = 'tenant-abc'
                ClientId        = 'client'
                KeyVaultName    = 'kv-test'
                KeyName         = 'msec-app'
                ThumbprintBytes = $Thumb
                Tokens          = @{}
            }
        }
    }

    It 'summarises a premium tenant: CA available, security defaults off, admins counted' {
        $s = 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 'identitySecurityDefaultsEnforcementPolicy' } -MockWith {
                # Security defaults are mutually exclusive with CA - off is correct here.
                [pscustomobject]@{ isEnabled = $false }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/policies/authorizationPolicy' } -MockWith {
                [pscustomobject]@{
                    guestUserRoleId = '10dae51f-b6af-4016-8d66-8c2a99b929b3'
                    allowInvitesFrom = 'adminsAndGuestInviters'
                    allowEmailVerifiedUsersToJoinOrganization = $false
                    defaultUserRolePermissions = [pscustomobject]@{
                        allowedToCreateApps           = $false
                        allowedToCreateSecurityGroups = $false
                        allowedToReadOtherUsers       = $true
                    }
                }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/subscribedSkus' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        skuId = 'sku-e5'; skuPartNumber = 'SPE_E5'; capabilityStatus = 'Enabled'
                        consumedUnits = 18
                        prepaidUnits = [pscustomobject]@{ enabled = 20; warning = 0; suspended = 0 }
                        servicePlans = @(
                            [pscustomobject]@{ servicePlanName = 'AAD_PREMIUM';        provisioningStatus = 'Success' }
                            [pscustomobject]@{ servicePlanName = 'AAD_PREMIUM_P2';     provisioningStatus = 'Success' }
                            [pscustomobject]@{ servicePlanName = 'INTUNE_A';           provisioningStatus = 'Success' }
                            [pscustomobject]@{ servicePlanName = 'EXCHANGE_S_ENTERPRISE'; provisioningStatus = 'Success' }
                            [pscustomobject]@{ servicePlanName = 'WINDEFATP';          provisioningStatus = 'Success' }
                            [pscustomobject]@{ servicePlanName = 'ATP_ENTERPRISE';     provisioningStatus = 'Success' }
                        )
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{ id = 'role-ga'; displayName = 'Global Administrator'
                                       templateId = '62e90394-69f5-4237-9190-012177145e10'; isBuiltIn = $true }
                    [pscustomobject]@{ id = 'role-sec'; displayName = 'Security Administrator'
                                       templateId = '194ae4cb-b126-40b2-bd5b-6091b380977d'; isBuiltIn = $true }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleAssignments' } -MockWith {
                $u1 = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u1'; userPrincipalName = 'a@x.com' }
                $u2 = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u2'; userPrincipalName = 'b@x.com' }
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{ id = 'as1'; roleDefinitionId = 'role-ga';  principalId = 'u1'; directoryScopeId = '/'; principal = $u1 }
                    [pscustomobject]@{ id = 'as2'; roleDefinitionId = 'role-ga';  principalId = 'u2'; directoryScopeId = '/'; principal = $u2 }
                    # u1 is ALSO a Security Admin - the distinct-principal count must not double-count.
                    [pscustomobject]@{ id = 'as3'; roleDefinitionId = 'role-sec'; principalId = 'u1'; directoryScopeId = '/'; principal = $u1 }
                ) }
            }

            Get-MsecEntraTenantSecuritySetting
        }

        $s.TenantId                        | Should -Be 'tenant-abc'
        $s.SecurityDefaultsEnabled         | Should -BeFalse

        $s.EntraIdPremium                  | Should -Be 'P2'
        $s.ConditionalAccessAvailable      | Should -BeTrue
        $s.IdentityProtectionAvailable     | Should -BeTrue
        $s.PimAvailable                    | Should -BeTrue
        $s.IntuneProvisioned               | Should -BeTrue
        $s.ExchangeOnlineProvisioned       | Should -BeTrue
        $s.DefenderForEndpointProvisioned  | Should -BeTrue
        $s.DefenderForOffice365Provisioned | Should -BeTrue
        $s.LicensedSkuCount                | Should -Be 1

        $s.DefaultUserRoleCanCreateApps    | Should -BeFalse
        $s.GuestUserRole                   | Should -Be 'Guest'
        $s.AllowInvitesFrom                | Should -Be 'adminsAndGuestInviters'

        $s.GlobalAdministratorCount        | Should -Be 2
        # 3 role memberships across 2 distinct people.
        $s.HighlyPrivilegedMemberCount     | Should -Be 2
        $s.ActivatedRoleCount              | Should -Be 2
        ($s.PrivilegedRoleSummary | Where-Object RoleName -eq 'Global Administrator').MemberCount | Should -Be 2

        $s.Notes.Count                     | Should -Be 0
        $s.PSObject.TypeNames              | Should -Contain 'MsecEntraTenantSecuritySetting'
    }

    It 'reports an unlicensed tenant as a real answer: CA unavailable, not unknown' {
        $s = 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 'identitySecurityDefaultsEnforcementPolicy' } -MockWith {
                [pscustomobject]@{ isEnabled = $true }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/policies/authorizationPolicy' } -MockWith {
                [pscustomobject]@{ defaultUserRolePermissions = [pscustomobject]@{ allowedToCreateApps = $true } }
            }
            # An Azure-infrastructure tenant: a couple of unrelated SKUs, no premium.
            # EXCHANGE_S_FOUNDATION rides along with Power BI Standard and must NOT be
            # read as a mail estate - it grants no mailboxes.
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/subscribedSkus' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{
                        skuId = 'sku-pbi'; skuPartNumber = 'POWER_BI_STANDARD'; consumedUnits = 2
                        prepaidUnits = [pscustomobject]@{ enabled = 1000000 }
                        servicePlans = @(
                            [pscustomobject]@{ servicePlanName = 'BI_AZURE_P0';           provisioningStatus = 'Success' }
                            [pscustomobject]@{ servicePlanName = 'EXCHANGE_S_FOUNDATION'; provisioningStatus = 'Success' }
                        )
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{ id = 'role-ga'; displayName = 'Global Administrator'
                                       templateId = '62e90394-69f5-4237-9190-012177145e10'; isBuiltIn = $true }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleAssignments' } -MockWith {
                [pscustomobject]@{ value = @(
                    1..6 | ForEach-Object {
                        $p = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'
                                                id = "u$_"; userPrincipalName = "admin$_@x.com" }
                        [pscustomobject]@{ id = "as$_"; roleDefinitionId = 'role-ga'
                                           principalId = "u$_"; directoryScopeId = '/'; principal = $p }
                    }
                ) }
            }

            Get-MsecEntraTenantSecuritySetting
        }

        # These are measured facts, not unknowns - so $false, never $null.
        $s.ConditionalAccessAvailable | Should -BeFalse
        $s.ConditionalAccessAvailable | Should -Not -BeNullOrEmpty -Because 'a read that succeeded must not look unmeasured'
        $s.EntraIdPremium             | Should -BeNullOrEmpty
        $s.IntuneProvisioned          | Should -BeFalse
        $s.PimAvailable               | Should -BeFalse
        # The bundled stub plan must NOT count as a mail estate, or an absent emailStats
        # domain would be reported as a fault to fix instead of as not-applicable.
        $s.ServicePlans               | Should -Contain 'EXCHANGE_S_FOUNDATION'
        $s.ExchangeOnlineProvisioned  | Should -BeFalse -Because 'EXCHANGE_S_FOUNDATION grants no mailboxes'

        # Security defaults carry the whole MFA story in a tenant with no CA.
        $s.SecurityDefaultsEnabled    | Should -BeTrue
        $s.GlobalAdministratorCount   | Should -Be 6
        $s.Notes.Count                | Should -Be 0
    }

    It 'degrades a failed section to $null + a Notes reason instead of throwing' {
        $s = 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 'identitySecurityDefaultsEnforcementPolicy' } -MockWith {
                [pscustomobject]@{ isEnabled = $false }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/policies/authorizationPolicy' } -MockWith {
                [pscustomobject]@{ defaultUserRolePermissions = [pscustomobject]@{ allowedToCreateApps = $false } }
            }
            # Licences unreadable - the app is missing Organization.Read.All.
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/subscribedSkus' } -MockWith {
                throw 'Response status code does not indicate success: 403 (Forbidden).'
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{ id = 'role-ga'; displayName = 'Global Administrator'
                                       templateId = '62e90394-69f5-4237-9190-012177145e10'; isBuiltIn = $true }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleAssignments' } -MockWith {
                $u1 = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'; id = 'u1'; userPrincipalName = 'a@x.com' }
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{ id = 'as1'; roleDefinitionId = 'role-ga'; principalId = 'u1'
                                       directoryScopeId = '/'; principal = $u1 }
                ) }
            }

            Get-MsecEntraTenantSecuritySetting
        }

        # Unmeasured is $null - crucially NOT $false, which would claim CA is unavailable.
        $s.ConditionalAccessAvailable | Should -BeNullOrEmpty
        $s.IntuneProvisioned          | Should -BeNullOrEmpty
        $s.ServicePlans               | Should -BeNullOrEmpty
        $s.LicensedSkuCount           | Should -BeNullOrEmpty

        # ...and the reason is recorded, naming the permission to grant.
        $s.Notes.Contains('licenses')  | Should -BeTrue
        $s.Notes['licenses']           | Should -BeLike '*Organization.Read.All*'

        # Sections that DID read still carry real values.
        $s.SecurityDefaultsEnabled    | Should -BeFalse
        $s.GlobalAdministratorCount   | Should -Be 1
    }

    It 'counts Global Admins when Graph calls the role by its legacy name' {
        # REGRESSION. Graph returns Global Administrator as 'Company Administrator' on a
        # great many tenants, and this count used to be `RoleName -eq 'Global
        # Administrator'` - which reported ZERO Global Admins on every one of them, in
        # the headline column of this report. The template id is identical everywhere.
        $s = 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 'identitySecurityDefaultsEnforcementPolicy' } -MockWith {
                [pscustomobject]@{ isEnabled = $false }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/policies/authorizationPolicy' } -MockWith {
                [pscustomobject]@{ defaultUserRolePermissions = [pscustomobject]@{ allowedToCreateApps = $false } }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/subscribedSkus' } -MockWith {
                [pscustomobject]@{ value = @() }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/organization' } -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ id = 'tenant-abc'; displayName = 'Contoso' }) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    # The legacy display name, as Graph is observed to return for this role.
                    [pscustomobject]@{ id = 'role-ga'; displayName = 'Company Administrator'
                                       templateId = '62e90394-69f5-4237-9190-012177145e10'; isBuiltIn = $true }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleAssignments' } -MockWith {
                [pscustomobject]@{ value = @(
                    1..3 | ForEach-Object {
                        $p = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user'
                                                id = "u$_"; userPrincipalName = "u$_@x.com" }
                        [pscustomobject]@{ id = "as$_"; roleDefinitionId = 'role-ga'
                                           principalId = "u$_"; directoryScopeId = '/'; principal = $p }
                    }
                ) }
            }

            Get-MsecEntraTenantSecuritySetting
        }

        $s.GlobalAdministratorCount    | Should -Be 3 -Because 'the count must key on roleTemplateId, not the display name'
        $s.HighlyPrivilegedMemberCount | Should -Be 3
        $s.ActivatedRoleCount          | Should -Be 1
        # The report still shows the name the directory actually uses, rather than
        # silently substituting a friendlier one.
        ($s.PrivilegedRoleSummary | Where-Object RoleName -eq 'Company Administrator').MemberCount | Should -Be 3
    }

    It 'counts a role held by a GROUP as one privileged principal' {
        # /directoryRoles does not expand groups, so the group is the assignee and the
        # holder is unknown. The count must come from PrincipalId - EffectiveId is $null
        # on exactly these rows and would silently drop them.
        $s = 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 'identitySecurityDefaultsEnforcementPolicy' } -MockWith {
                [pscustomobject]@{ isEnabled = $false }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/policies/authorizationPolicy' } -MockWith {
                [pscustomobject]@{ defaultUserRolePermissions = [pscustomobject]@{ allowedToCreateApps = $false } }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/subscribedSkus' } -MockWith {
                [pscustomobject]@{ value = @() }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/organization' } -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ id = 'tenant-abc' }) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{ id = 'role-ga'; displayName = 'Company Administrator'
                                       templateId = '62e90394-69f5-4237-9190-012177145e10'; isBuiltIn = $true }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleAssignments' } -MockWith {
                $u1 = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.user';  id = 'u1'; userPrincipalName = 'a@x.com' }
                $g1 = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.group'; id = 'g1'; displayName = 'sg-admins' }
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{ id = 'as1'; roleDefinitionId = 'role-ga'; principalId = 'u1'; directoryScopeId = '/'; principal = $u1 }
                    [pscustomobject]@{ id = 'as2'; roleDefinitionId = 'role-ga'; principalId = 'g1'; directoryScopeId = '/'; principal = $g1 }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/transitiveMembers' } -MockWith {
                [pscustomobject]@{ value = @() }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/transitiveMembers/microsoft\.graph\.user' } -MockWith {
                [pscustomobject]@{ value = @(
                    # u1 is BOTH directly assigned and in the group - one administrator.
                    [pscustomobject]@{ id = 'u1'; userPrincipalName = 'a@x.com' }
                    [pscustomobject]@{ id = 'u2'; userPrincipalName = 'b@x.com' }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'eligibilityScheduleInstances' } -MockWith {
                [pscustomobject]@{ value = @() }
            }

            Get-MsecEntraTenantSecuritySetting -WarningAction SilentlyContinue
        }

        # Two PEOPLE, not two assignments and not one group: the group is expanded, and
        # u1 - directly assigned AND inside the group - counts once. The older
        # /directoryRoles view reported this tenant as 1 user + 1 opaque group.
        $s.GlobalAdministratorCount    | Should -Be 2
        $s.HighlyPrivilegedMemberCount | Should -Be 2
        ($s.PrivilegedRoleSummary | Where-Object RoleName -eq 'Company Administrator').MemberCount | Should -Be 2
    }

    It 'counts an unexpandable group as one principal rather than dropping it' {
        # Whoever can write that group's membership can take the role tomorrow, so the
        # assignment must survive into the count even with no holder resolved.
        $s = 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 'identitySecurityDefaultsEnforcementPolicy' } -MockWith {
                [pscustomobject]@{ isEnabled = $false }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/policies/authorizationPolicy' } -MockWith {
                [pscustomobject]@{ defaultUserRolePermissions = [pscustomobject]@{ allowedToCreateApps = $false } }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/subscribedSkus' } -MockWith {
                [pscustomobject]@{ value = @() }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/organization' } -MockWith {
                [pscustomobject]@{ value = @([pscustomobject]@{ id = 'tenant-abc' }) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleDefinitions' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{ id = 'role-ga'; displayName = 'Global Administrator'
                                       templateId = '62e90394-69f5-4237-9190-012177145e10'; isBuiltIn = $true }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/roleAssignments' } -MockWith {
                $g1 = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.group'; id = 'g1'; displayName = 'sg-admins' }
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{ id = 'as1'; roleDefinitionId = 'role-ga'; principalId = 'g1'; directoryScopeId = '/'; principal = $g1 }
                ) }
            }
            # Group.Read.All missing: no cast succeeds, and PIM-for-groups fails too.
            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' } -MockWith {
                throw 'Response status code does not indicate success: 403 (Forbidden).'
            }

            Get-MsecEntraTenantSecuritySetting -WarningAction SilentlyContinue
        }

        $s.GlobalAdministratorCount    | Should -Be 1 -Because 'an unresolved group assignment is still a privilege path'
        $s.HighlyPrivilegedMemberCount | Should -Be 1
    }

    It 'rethrows the annotated error under -Strict' {
        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 'identitySecurityDefaultsEnforcementPolicy' } -MockWith {
                throw 'Response status code does not indicate success: 403 (Forbidden).'
            }

            { Get-MsecEntraTenantSecuritySetting -Strict } |
                Should -Throw -ExpectedMessage '*Policy.Read.All*'
        }
    }
}