Tests/Get-MsecEntraMfaRegistrationStats.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecEntraMfaRegistrationStats. The behaviours that matter:
# - coverage counts IsMfaCapable, not IsMfaRegistered (registered-but-disabled must not
# be reported as covered)
# - admins without MFA are counted AND named, since a bare count isn't actionable
# - PhoneOnlyMfaCapable catches the "100% covered, all of it SMS" case
# - an empty population yields $null percentages, never 0

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-MsecEntraMfaRegistrationStats' {
    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 'aggregates coverage, names the admins without MFA, and flags phone-only users' {
        $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 'userRegistrationDetails' } -MockWith {
                [pscustomobject]@{ value = @(
                    # admin, strong MFA
                    [pscustomobject]@{ id='a1'; userPrincipalName='ga1@x.com'; userType='member'; isAdmin=$true
                                       isMfaRegistered=$true; isMfaCapable=$true; isPasswordlessCapable=$true
                                       isSsprCapable=$true
                                       methodsRegistered=@('microsoftAuthenticatorPush','fido2SecurityKey') }
                    # admin, phone-only MFA - capable, but phishable
                    [pscustomobject]@{ id='a2'; userPrincipalName='ga2@x.com'; userType='member'; isAdmin=$true
                                       isMfaRegistered=$true; isMfaCapable=$true; isPasswordlessCapable=$false
                                       isSsprCapable=$true
                                       methodsRegistered=@('sms','mobilePhone') }
                    # admin, NOTHING registered - the headline finding
                    [pscustomobject]@{ id='a3'; userPrincipalName='breakglass@x.com'; userType='member'; isAdmin=$true
                                       isMfaRegistered=$false; isMfaCapable=$false; isPasswordlessCapable=$false
                                       isSsprCapable=$false
                                       methodsRegistered=@() }
                    # admin, registered but the method is DISABLED by policy -> not capable
                    [pscustomobject]@{ id='a4'; userPrincipalName='stale-admin@x.com'; userType='member'; isAdmin=$true
                                       isMfaRegistered=$true; isMfaCapable=$false; isPasswordlessCapable=$false
                                       isSsprCapable=$false
                                       methodsRegistered=@('sms') }
                    # ordinary member, strong
                    [pscustomobject]@{ id='u1'; userPrincipalName='user1@x.com'; userType='member'; isAdmin=$false
                                       isMfaRegistered=$true; isMfaCapable=$true; isPasswordlessCapable=$false
                                       isSsprCapable=$true
                                       methodsRegistered=@('microsoftAuthenticatorPush') }
                    # guest, no MFA
                    [pscustomobject]@{ id='g1'; userPrincipalName='guest@partner.test'; userType='guest'; isAdmin=$false
                                       isMfaRegistered=$false; isMfaCapable=$false; isPasswordlessCapable=$false
                                       isSsprCapable=$false
                                       methodsRegistered=@() }
                ) }
            }

            Get-MsecEntraMfaRegistrationStats
        }

        $s.TotalUsers | Should -Be 6
        $s.Members    | Should -Be 5
        $s.Guests     | Should -Be 1

        # 5 registered (a1,a2,a4,u1 ... a4 registered but not capable) -> registered 4, capable 3
        $s.MfaRegistered        | Should -Be 4
        $s.MfaCapable           | Should -Be 3
        $s.NotMfaCapable        | Should -Be 3
        $s.MfaCapablePercent    | Should -Be 50.0

        # Admins: 4 total, 2 capable (a1, a2), 2 not (a3, a4)
        $s.AdminTotal             | Should -Be 4
        $s.AdminMfaCapable        | Should -Be 2
        $s.AdminMfaCapablePercent | Should -Be 50.0
        $s.AdminsNotMfaCapable    | Should -Be 2
        # Named, not just counted - a count alone can't be acted on.
        $s.AdminsNotMfaCapableUpn | Should -Be @('breakglass@x.com','stale-admin@x.com')

        # a2 is MFA-capable but only via phone methods.
        $s.PhoneOnlyMfaCapable        | Should -Be 1
        $s.PhoneOnlyMfaCapablePercent | Should -Be 33.33
        # a3's empty method list must NOT satisfy "all methods are phone" vacuously.
        $s.PhoneOnlyMfaCapable        | Should -Not -Be 2

        $s.PasswordlessCapable | Should -Be 1
        $s.GuestsMfaCapable    | Should -Be 0
        $s.SsprCapable         | Should -Be 3   # a1, a2, u1

        # Method mix, users counted once per method they hold.
        $s.ByMethod['microsoftAuthenticatorPush'] | Should -Be 2
        $s.ByMethod['sms']                        | Should -Be 2
        $s.ByMethod['fido2SecurityKey']           | Should -Be 1
    }

    It 'returns null percentages rather than 0 when there are no users' {
        $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 'userRegistrationDetails' } -MockWith {
                [pscustomobject]@{ value = @() }
            }

            Get-MsecEntraMfaRegistrationStats
        }

        $s.TotalUsers             | Should -Be 0
        $s.MfaCapable             | Should -Be 0
        # 0% would read as "measured, nobody covered"; null says "no population".
        $s.MfaCapablePercent      | Should -BeNullOrEmpty
        $s.AdminMfaCapablePercent | Should -BeNullOrEmpty
        $s.AdminsNotMfaCapable    | Should -Be 0
        $s.AdminsNotMfaCapableUpn | Should -BeNullOrEmpty
    }

    It 'propagates the licensing 403 from the underlying report' {
        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 'userRegistrationDetails' } -MockWith {
                $body = '{"error":{"message":"Tenant is not a B2C tenant and doesn''t have premium license"}}'
                $ex   = [System.Exception]::new('Response status code does not indicate success: 403 (Forbidden).')
                $rec  = [System.Management.Automation.ErrorRecord]::new($ex, 'HttpResponse403', 'PermissionDenied', $null)
                $rec.ErrorDetails = [System.Management.Automation.ErrorDetails]::new($body)
                throw $rec
            }

            { Get-MsecEntraMfaRegistrationStats } | Should -Throw -ExpectedMessage '*LICENSING limit*'
        }
    }
}