Tests/Get-MsecEntraMfaRegistration.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecEntraMfaRegistration. Covers the projection, the array coercion on
# methodsRegistered, and the two DIFFERENT 403s this premium-gated report returns - the
# licensing one must not blame the permission, or it sends people through a consent cycle
# that cannot help.

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-MsecEntraMfaRegistration' {
    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 'projects each user, keeping IsMfaRegistered and IsMfaCapable distinct' {
        $rows = 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 = @(
                    [pscustomobject]@{
                        id = 'u1'; userPrincipalName = 'admin@example.com'; userDisplayName = 'Admin One'
                        userType = 'member'; isAdmin = $true
                        isMfaRegistered = $true; isMfaCapable = $true; isPasswordlessCapable = $true
                        isSsprRegistered = $true; isSsprEnabled = $true; isSsprCapable = $true
                        methodsRegistered = @('microsoftAuthenticatorPush','fido2SecurityKey')
                        # System-preferred is ON, so Entra's ranked choice wins and the
                        # user's own preference ('sms') is what a sign-in would NOT use.
                        userPreferredMethodForSecondaryAuthentication = 'sms'
                        systemPreferredAuthenticationMethods = @('push','sms')
                        isSystemPreferredAuthenticationMethodEnabled = $true
                        lastUpdatedDateTime = '2026-08-01T10:00:00Z'
                    }
                    [pscustomobject]@{
                        # Registered a method the tenant has since DISABLED: registered but
                        # not capable. Counting this as coverage would overstate it.
                        id = 'u2'; userPrincipalName = 'stale@example.com'; userDisplayName = 'Stale'
                        userType = 'member'; isAdmin = $false
                        isMfaRegistered = $true; isMfaCapable = $false; isPasswordlessCapable = $false
                        isSsprRegistered = $false; isSsprEnabled = $false; isSsprCapable = $false
                        methodsRegistered = @('sms')
                        # System-preferred is OFF, so the user's own choice stands.
                        userPreferredMethodForSecondaryAuthentication = 'sms'
                        systemPreferredAuthenticationMethods = @('push')
                        isSystemPreferredAuthenticationMethodEnabled = $false
                        lastUpdatedDateTime = $null
                    }
                    [pscustomobject]@{
                        # Nothing registered: the system has no ranked list to offer even
                        # though the tenant toggle is on, so the user's 'none' is the
                        # only truthful answer.
                        id = 'u3'; userPrincipalName = 'guest@partner.test'; userDisplayName = 'Guest'
                        userType = 'guest'; isAdmin = $false
                        isMfaRegistered = $false; isMfaCapable = $false
                        methodsRegistered = $null
                        userPreferredMethodForSecondaryAuthentication = 'none'
                        systemPreferredAuthenticationMethods = $null
                        isSystemPreferredAuthenticationMethodEnabled = $true
                    }
                ) }
            }

            Get-MsecEntraMfaRegistration
        }

        $rows.Count | Should -Be 3

        $a = $rows | Where-Object UserId -eq 'u1'
        $a.UserPrincipalName     | Should -Be 'admin@example.com'
        $a.UserType              | Should -Be 'member'
        $a.IsAdmin               | Should -BeTrue
        $a.IsMfaCapable          | Should -BeTrue
        $a.IsPasswordlessCapable | Should -BeTrue
        $a.MethodsRegistered     | Should -Contain 'fido2SecurityKey'
        $a.LastUpdatedDateTime   | Should -BeOfType [datetime]

        # There is no defaultMfaMethod field in Graph v1.0 - the effective default is
        # derived. With system-preferred ON, Entra's ranked first choice wins and the
        # user's own 'sms' preference is NOT what a sign-in would prompt for. Reading
        # the user field alone would report this admin as phishable when they are not.
        $a.DefaultMfaMethod            | Should -Be 'push'
        $a.UserPreferredMfaMethod      | Should -Be 'sms'
        $a.SystemPreferredMfaMethods   | Should -Contain 'push'
        $a.IsSystemPreferredMfaEnabled | Should -BeTrue
        $a.Raw.id                | Should -Be 'u1'
        $a.PSObject.TypeNames    | Should -Contain 'MsecEntraMfaRegistration'

        # The distinction that drives coverage reporting.
        $b = $rows | Where-Object UserId -eq 'u2'
        $b.IsMfaRegistered | Should -BeTrue
        $b.IsMfaCapable    | Should -BeFalse

        # System-preferred OFF: the user's own choice stands, even though Entra has a
        # stronger one on file. Reading the system list alone would report this user as
        # using push when every sign-in actually prompts for SMS.
        $b.DefaultMfaMethod            | Should -Be 'sms'
        $b.SystemPreferredMfaMethods   | Should -Contain 'push'
        $b.IsSystemPreferredMfaEnabled | Should -BeFalse

        # Missing methodsRegistered coerces to an empty ARRAY, not $null - a scriptblock
        # returning @() emits nothing and lands as $null unless comma-wrapped.
        $c = $rows | Where-Object UserId -eq 'u3'
        ($c.MethodsRegistered -is [array]) | Should -BeTrue
        $c.MethodsRegistered.Count         | Should -Be 0

        # System-preferred is ON but the ranked list is empty - nothing is registered.
        # Indexing [0] into that would throw or yield $null; the user's own 'none' is
        # both truthful and the more useful answer.
        $c.DefaultMfaMethod          | Should -Be 'none'
        # An empty array rather than $null, so -contains and .Count stay safe. Tested
        # with -is rather than a pipeline, which would unroll the empty array to $null.
        ($c.SystemPreferredMfaMethods -is [array]) | Should -BeTrue
        $c.SystemPreferredMfaMethods.Count         | Should -Be 0
        { $rows | Where-Object MethodsRegistered -contains 'sms' } | Should -Not -Throw
        ($rows | Where-Object MethodsRegistered -contains 'sms').UserId | Should -Be 'u2'
        $c.LastUpdatedDateTime | Should -BeNullOrEmpty
    }

    It 'reads legacy per-user MFA state only when asked, and never touches beta otherwise' {
        $result = 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 = @(
                    [pscustomobject]@{
                        id = 'u1'; userPrincipalName = 'admin@example.com'
                        isAdmin = $true; isMfaRegistered = $true; isMfaCapable = $true
                        userPreferredMethodForSecondaryAuthentication = 'push'
                    }
                    [pscustomobject]@{
                        id = 'u2'; userPrincipalName = 'legacy@example.com'
                        isAdmin = $false; isMfaRegistered = $true; isMfaCapable = $true
                        userPreferredMethodForSecondaryAuthentication = 'sms'
                    }
                ) }
            }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/beta/users/u1/authentication/requirements' } -MockWith {
                [pscustomobject]@{ perUserMfaState = 'enforced' }
            }
            # One user's state is unreadable: the other rows must still come back, and
            # the run must say so rather than pass $null off as 'disabled'.
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/beta/users/u2/authentication/requirements' } -MockWith {
                throw 'Response status code does not indicate success: 403 (Forbidden).'
            }

            $off = @(Get-MsecEntraMfaRegistration)
            # Default must make NO beta calls at all - it is an unsupported API and
            # one request per user, so it cannot be paid for silently.
            Should -Invoke Invoke-RestMethod -Times 0 -Exactly -ParameterFilter { $Uri -match '/beta/' }

            $warnings = @()
            $on = @(Get-MsecEntraMfaRegistration -IncludePerUserMfaState -WarningVariable warnings -WarningAction SilentlyContinue)
            [pscustomobject]@{ Off = $off; On = $on; Warnings = @($warnings) }
        }

        # The property exists either way, so the shape does not change with the switch.
        $result.Off[0].PSObject.Properties.Name | Should -Contain 'PerUserMfaState'
        $result.Off[0].PerUserMfaState          | Should -BeNullOrEmpty

        # An 'enforced' user is challenged whatever Conditional Access says - the fact
        # that no other report in this module can see.
        ($result.On | Where-Object UserId -eq 'u1').PerUserMfaState | Should -Be 'enforced'

        # A failed read stays $null, and is counted rather than mistaken for 'disabled'.
        ($result.On | Where-Object UserId -eq 'u2').PerUserMfaState | Should -BeNullOrEmpty
        ($result.Warnings -join ' ') | Should -Match '1 user'
    }

    It 'retries a throttled per-user call instead of reporting it as unreadable' {
        # A one-call-per-user loop is exactly what Graph throttles. Before this, a 429
        # was caught and turned into $null - so a large tenant came back with mostly
        # empty states and a warning, which reads as a missing permission rather than
        # as backpressure.
        $result = 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 }
            }
            # Never actually wait in a test; only prove the wait was asked for.
            Mock Start-Sleep -MockWith { }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'userRegistrationDetails' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{ id = 'u1'; userPrincipalName = 'admin@example.com'; isAdmin = $true }
                ) }
            }

            $script:ThrottleCalls = 0
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/beta/users/u1/authentication/requirements' } -MockWith {
                $script:ThrottleCalls++
                if ($script:ThrottleCalls -lt 3) {
                    throw 'Response status code does not indicate success: 429 (Too Many Requests).'
                }
                [pscustomobject]@{ perUserMfaState = 'enabled' }
            }

            $warnings = @()
            $rows = @(Get-MsecEntraMfaRegistration -IncludePerUserMfaState -WarningVariable warnings -WarningAction SilentlyContinue)
            [pscustomobject]@{ Rows = $rows; Warnings = @($warnings); Calls = $script:ThrottleCalls }
        }

        # Two 429s, then the real answer - and the answer is what lands on the row.
        $result.Calls                  | Should -Be 3
        $result.Rows[0].PerUserMfaState | Should -Be 'enabled'
        # Nothing to warn about: backpressure is not a failure.
        $result.Warnings.Count         | Should -Be 0
    }

    It 'gives up on a persistently throttled call rather than retrying forever' {
        $result = 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 Start-Sleep -MockWith { }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'userRegistrationDetails' } -MockWith {
                [pscustomobject]@{ value = @(
                    [pscustomobject]@{ id = 'u1'; userPrincipalName = 'admin@example.com' }
                ) }
            }
            $script:AlwaysThrottled = 0
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/beta/users/u1/authentication/requirements' } -MockWith {
                $script:AlwaysThrottled++
                throw 'Response status code does not indicate success: 429 (Too Many Requests).'
            }

            $warnings = @()
            $rows = @(Get-MsecEntraMfaRegistration -IncludePerUserMfaState -WarningVariable warnings -WarningAction SilentlyContinue)
            [pscustomobject]@{ Rows = $rows; Warnings = @($warnings); Calls = $script:AlwaysThrottled }
        }

        # Bounded: 5 attempts, not an infinite loop, and the row still comes back.
        $result.Calls                   | Should -Be 5
        $result.Rows.Count              | Should -Be 1
        $result.Rows[0].PerUserMfaState  | Should -BeNullOrEmpty
        ($result.Warnings -join ' ')     | Should -Match '1 user'
    }

    It 'rewrites a bare 403 to mention the missing AuditLog.Read.All permission' {
        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 {
                throw 'Response status code does not indicate success: 403 (Forbidden).'
            }

            { Get-MsecEntraMfaRegistration } | Should -Throw -ExpectedMessage '*AuditLog.Read.All*'
        }
    }

    It 'identifies the premium-licensing 403 and does NOT blame the permission' {
        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":{"code":"Authentication_RequestFromUnsupportedUserRole","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
            }

            $msg = try { Get-MsecEntraMfaRegistration; $null } catch { $_.Exception.Message }

            $msg | Should -BeLike '*premium license*'
            $msg | Should -BeLike '*LICENSING limit*'
            $msg | Should -BeLike '*will not change it*'
            $msg | Should -BeLike '*not measurable in this tenant*'
        }
    }
}