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 'scopes the member percentages to members, so guests cannot dilute them' {
        # Measured on a live tenant: 177 members, 202 guests. All-user SSPR coverage read
        # 44.33% against a true member figure of 94.92%, and MFA 61.48% against 95.48%. A
        # guest-heavy tenant is the normal case, not an edge case, so the denominator is
        # the whole finding.
        $s = InModuleScope Msec {
            Mock Get-MsecEntraMfaRegistration -MockWith {
                # 2 members, both covered on everything.
                [pscustomobject]@{ UserType = 'member'; IsMfaCapable = $true;  IsMfaRegistered = $true
                                   IsPasswordlessCapable = $true; IsSsprCapable = $true; IsAdmin = $false
                                   MethodsRegistered = @('fido2SecurityKey') }
                [pscustomobject]@{ UserType = 'member'; IsMfaCapable = $true;  IsMfaRegistered = $true
                                   IsPasswordlessCapable = $true; IsSsprCapable = $true; IsAdmin = $false
                                   MethodsRegistered = @('fido2SecurityKey') }
                # 6 guests. A guest resets their password in their HOME tenant, so none is
                # ever SSPR-capable here - that is structural, not a gap to close.
                1..6 | ForEach-Object {
                    [pscustomobject]@{ UserType = 'guest'; IsMfaCapable = $false; IsMfaRegistered = $false
                                       IsPasswordlessCapable = $false; IsSsprCapable = $false; IsAdmin = $false
                                       MethodsRegistered = @() }
                }
            }
            Get-MsecEntraMfaRegistrationStats
        }

        $s.Members | Should -Be 2
        $s.Guests  | Should -Be 6

        # The member view: full coverage, which is the truth about the workforce.
        $s.MembersMfaCapablePercent          | Should -Be 100
        $s.MembersSsprCapablePercent         | Should -Be 100
        $s.MembersPasswordlessCapablePercent | Should -Be 100

        # The all-user view, kept for continuity of an existing workbook's history, is
        # diluted by exactly the guests - 2 of 8.
        $s.MfaCapablePercent  | Should -Be 25
        $s.SsprCapablePercent | Should -Be 25

        # Visible rather than implied: guests contribute nothing to SSPR.
        $s.GuestsSsprCapable  | Should -Be 0
        $s.MembersSsprCapable | Should -Be 2

        # The count somebody reads out in a meeting. All-user says 6 cannot do MFA; the
        # truth about the workforce is 0. Measured live the same shape gave 146 against 8.
        $s.NotMfaCapable        | Should -Be 6
        $s.MembersNotMfaCapable | Should -Be 0
    }

    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*'
        }
    }
}