Tests/Get-MsecEntraMfaEvidence.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecEntraMfaEvidence. The behaviours that matter are the ones that would
# make an audit response WRONG while looking complete: a user with no sign-ins being
# reported as covered, a non-interactive token refresh counted as a single-factor
# sign-in, a group-based policy exclusion going unresolved, and a failed MFA challenge
# being mistaken for a sign-in that skipped MFA.

BeforeAll {
    $modulePath = Join-Path $PSScriptRoot '..' 'Msec.psm1'
    Import-Module $modulePath -Force -ErrorAction Stop
}

AfterAll {
    Remove-Module Msec -Force -ErrorAction SilentlyContinue
}

Describe 'Get-MsecEntraMfaEvidence' {

    It 'assigns each user the right evidence bucket and resolves group-based exclusions' {
        $result = InModuleScope Msec {
            # The three underlying commands are mocked directly: this function's job is
            # the JOIN, and mocking Graph three times over would test their parsing again
            # rather than the logic under test here.
            Mock Assert-MsecSession -MockWith { }
            Mock Get-MsecEntraMfaRegistration -MockWith {
                @(
                    [pscustomobject]@{ UserId = 'u-ok';     UserPrincipalName = 'ok@example.com'
                                       DisplayName = 'Satisfied'; UserType = 'member'; IsAdmin = $true
                                       IsMfaCapable = $true;  IsMfaRegistered = $true;  DefaultMfaMethod = 'push' }
                    [pscustomobject]@{ UserId = 'u-single'; UserPrincipalName = 'single@example.com'
                                       DisplayName = 'Single factor'; UserType = 'member'; IsAdmin = $false
                                       IsMfaCapable = $true;  IsMfaRegistered = $true;  DefaultMfaMethod = 'sms' }
                    [pscustomobject]@{ UserId = 'u-quiet';  UserPrincipalName = 'quiet@example.com'
                                       DisplayName = 'Dormant admin'; UserType = 'member'; IsAdmin = $true
                                       IsMfaCapable = $false; IsMfaRegistered = $false; DefaultMfaMethod = 'none' }
                    [pscustomobject]@{ UserId = 'u-excl';   UserPrincipalName = 'breakglass@example.com'
                                       DisplayName = 'Break glass'; UserType = 'member'; IsAdmin = $true
                                       IsMfaCapable = $true;  IsMfaRegistered = $true;  DefaultMfaMethod = 'push' }
                    # Guests are excluded by default - their home tenant governs them.
                    [pscustomobject]@{ UserId = 'u-guest';  UserPrincipalName = 'guest@partner.test'
                                       DisplayName = 'Guest'; UserType = 'guest'; IsAdmin = $false
                                       IsMfaCapable = $false; IsMfaRegistered = $false; DefaultMfaMethod = 'none' }
                )
            }
            Mock Get-MsecEntraConditionalAccessPolicy -MockWith {
                @(
                    [pscustomobject]@{
                        DisplayName = 'Require MFA for all users'; State = 'enabled'
                        Requires = @('mfa'); IncludedUsers = @('All')
                        ExcludedUsers = @(); ExcludedGroups = @('g-breakglass'); ExcludedRoles = @()
                    }
                    # Narrower scope: reported, but scope deliberately NOT inferred.
                    [pscustomobject]@{
                        DisplayName = 'MFA for finance app'; State = 'enabled'
                        Requires = @('mfa'); IncludedUsers = @('u-single')
                        ExcludedUsers = @(); ExcludedGroups = @(); ExcludedRoles = @()
                    }
                    # Disabled policies must never count towards coverage.
                    [pscustomobject]@{
                        DisplayName = 'Old disabled MFA policy'; State = 'disabled'
                        Requires = @('mfa'); IncludedUsers = @('All')
                        ExcludedUsers = @(); ExcludedGroups = @(); ExcludedRoles = @()
                    }
                )
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'groups/g-breakglass/transitiveMembers' } -MockWith {
                @([pscustomobject]@{ id = 'u-excl' })
            }
            Mock Get-MsecEntraConditionalAccessSignInLog -MockWith {
                @(
                    [pscustomobject]@{ UserId = 'u-ok'; IsInteractive = $true
                                       CreatedDateTime = [datetime]'2026-08-01T09:00:00Z'
                                       MfaSatisfied = $true; MfaFailed = $false
                                       MfaRequiredByPolicies = @('Require MFA for all users') }
                    [pscustomobject]@{ UserId = 'u-ok'; IsInteractive = $true
                                       CreatedDateTime = [datetime]'2026-08-05T09:00:00Z'
                                       MfaSatisfied = $true; MfaFailed = $false
                                       MfaRequiredByPolicies = @('Require MFA for all users') }
                    # No MFA policy fired: one factor was enough.
                    [pscustomobject]@{ UserId = 'u-single'; IsInteractive = $true
                                       CreatedDateTime = [datetime]'2026-08-04T09:00:00Z'
                                       MfaSatisfied = $false; MfaFailed = $false
                                       MfaRequiredByPolicies = @() }
                    # NON-interactive: a token refresh. Counting it as single-factor would
                    # invent a finding for a user who never had a prompt to answer.
                    [pscustomobject]@{ UserId = 'u-quiet'; IsInteractive = $false
                                       CreatedDateTime = [datetime]'2026-08-06T09:00:00Z'
                                       MfaSatisfied = $false; MfaFailed = $false
                                       MfaRequiredByPolicies = @() }
                )
            }

            @(Get-MsecEntraMfaEvidence -Days 30 -WarningAction SilentlyContinue)
        }

        # Guests excluded by default.
        $result.Count | Should -Be 4
        $result | Where-Object UserId -eq 'u-guest' | Should -BeNullOrEmpty

        # Evidence of the control OPERATING - two satisfied sign-ins, most recent kept.
        $ok = $result | Where-Object UserId -eq 'u-ok'
        $ok.EvidenceStatus      | Should -Be 'MfaSatisfied'
        $ok.MfaSatisfiedSignIns | Should -Be 2
        $ok.SingleFactorSignIns | Should -Be 0
        $ok.LastMfaSatisfiedUtc | Should -Be ([datetime]'2026-08-05T09:00:00Z')
        $ok.EnforcingPolicies   | Should -Contain 'Require MFA for all users'
        $ok.InScopeOfMfaPolicies | Should -Contain 'Require MFA for all users'

        # A finding: an interactive sign-in completed with no MFA policy applied.
        $single = $result | Where-Object UserId -eq 'u-single'
        $single.EvidenceStatus      | Should -Be 'SingleFactorObserved'
        $single.SingleFactorSignIns | Should -Be 1

        # The dormant admin had ONLY a non-interactive sign-in, so nothing is evidenced.
        # This must never read as a pass - it is where unused admin accounts hide.
        $quiet = $result | Where-Object UserId -eq 'u-quiet'
        $quiet.EvidenceStatus     | Should -Be 'NoSignInInWindow'
        $quiet.InteractiveSignIns | Should -Be 0
        $quiet.IsMfaCapable       | Should -BeFalse

        # The break-glass account is excluded via a GROUP - an indirection a policy
        # listing shows only as a GUID - and the reason is named.
        $excl = $result | Where-Object UserId -eq 'u-excl'
        $excl.ExcludedFromMfaPolicies.Count | Should -Be 1
        $excl.ExcludedFromMfaPolicies[0]    | Should -Match 'Require MFA for all users'
        $excl.ExcludedFromMfaPolicies[0]    | Should -Match 'group g-breakglass'
        $excl.InScopeOfMfaPolicies          | Should -BeNullOrEmpty

        # A disabled policy never counts, and a narrower one is reported without having
        # its scope guessed at.
        $ok.InScopeOfMfaPolicies    | Should -Not -Contain 'Old disabled MFA policy'
        $ok.OtherMfaPolicies        | Should -Contain 'MFA for finance app'

        # The window has to be on the row: evidence is only as good as its period.
        $ok.WindowDays | Should -Be 30
        $ok.PSObject.TypeNames | Should -Contain 'MsecEntraMfaEvidence'
    }

    It 'does not mistake a FAILED MFA challenge for a sign-in that skipped MFA' {
        $result = InModuleScope Msec {
            Mock Assert-MsecSession -MockWith { }
            Mock Get-MsecEntraMfaRegistration -MockWith {
                @([pscustomobject]@{ UserId = 'u1'; UserPrincipalName = 'u1@example.com'
                                     UserType = 'member'; IsAdmin = $false
                                     IsMfaCapable = $true; IsMfaRegistered = $true; DefaultMfaMethod = 'push' })
            }
            Mock Get-MsecEntraConditionalAccessPolicy -MockWith {
                @([pscustomobject]@{ DisplayName = 'Require MFA'; State = 'enabled'; Requires = @('mfa')
                                     IncludedUsers = @('All'); ExcludedUsers = @(); ExcludedGroups = @(); ExcludedRoles = @() })
            }
            Mock Get-MsecEntraConditionalAccessSignInLog -MockWith {
                # The policy demanded MFA and the challenge FAILED. That is the control
                # working, not a bypass - counting it as single-factor would turn every
                # blocked sign-in into an audit finding.
                @([pscustomobject]@{ UserId = 'u1'; IsInteractive = $true
                                     CreatedDateTime = [datetime]'2026-08-04T09:00:00Z'
                                     MfaSatisfied = $false; MfaFailed = $true
                                     MfaRequiredByPolicies = @('Require MFA') })
            }

            @(Get-MsecEntraMfaEvidence -WarningAction SilentlyContinue)
        }

        $result[0].SingleFactorSignIns | Should -Be 0
        $result[0].MfaSatisfiedSignIns | Should -Be 0
        # Not satisfied, but emphatically not a bypass either - the policy challenged
        # them and they failed it. Calling that 'SingleFactorObserved' would report the
        # control working as if it had been circumvented.
        $result[0].EvidenceStatus      | Should -Be 'NoSuccessfulSignIn'
    }

    It 'does not count a FAILED sign-in as single-factor access, and surfaces legacy auth' {
        $result = InModuleScope Msec {
            Mock Assert-MsecSession -MockWith { }
            Mock Get-MsecEntraMfaRegistration -MockWith {
                @(
                    [pscustomobject]@{ UserId = 'u-fail'; UserPrincipalName = 'typo@example.com'; UserType = 'member'
                                       IsAdmin = $false; IsMfaCapable = $true; IsMfaRegistered = $true; DefaultMfaMethod = 'push' }
                    [pscustomobject]@{ UserId = 'u-legacy'; UserPrincipalName = 'legacy@example.com'; UserType = 'member'
                                       IsAdmin = $true; IsMfaCapable = $true; IsMfaRegistered = $true; DefaultMfaMethod = 'push' }
                )
            }
            Mock Get-MsecEntraConditionalAccessPolicy -MockWith { @() }
            Mock Get-MsecEntraConditionalAccessSignInLog -MockWith {
                @(
                    # Wrong password. No MFA policy fired because the sign-in never got
                    # that far - counting this would make every mistyped credential a
                    # finding, and would flag a user who did not get in at all.
                    [pscustomobject]@{ UserId = 'u-fail'; IsInteractive = $true
                                       CreatedDateTime = [datetime]'2026-08-04T09:00:00Z'
                                       MfaSatisfied = $false; MfaFailed = $false
                                       MfaRequiredByPolicies = @(); ResultCode = 50126
                                       AppDisplayName = 'Office 365'; ClientAppUsed = 'Browser' }
                    # Succeeded on one factor over a LEGACY protocol. Conditional Access
                    # cannot challenge basic auth, only block it, so no MFA policy is in
                    # the path at all - the serious version of this finding.
                    [pscustomobject]@{ UserId = 'u-legacy'; IsInteractive = $true
                                       CreatedDateTime = [datetime]'2026-08-06T09:00:00Z'
                                       MfaSatisfied = $false; MfaFailed = $false
                                       MfaRequiredByPolicies = @(); ResultCode = 0
                                       AppDisplayName = 'Office 365 Exchange Online'
                                       ClientAppUsed = 'IMAP4' }
                )
            }

            @(Get-MsecEntraMfaEvidence -WarningAction SilentlyContinue)
        }

        # A rejected sign-in is not access, so this user has nothing to evidence either
        # way - not a finding, and not a pass.
        $failed = $result | Where-Object UserId -eq 'u-fail'
        $failed.SingleFactorSignIns | Should -Be 0
        # They attempted and were rejected: distinct from never having signed in, and
        # distinct from getting in on one factor.
        $failed.EvidenceStatus      | Should -Be 'NoSuccessfulSignIn'
        $failed.InteractiveSignIns  | Should -Be 1

        # The real finding, with the detail needed to act on it.
        $legacy = $result | Where-Object UserId -eq 'u-legacy'
        $legacy.EvidenceStatus         | Should -Be 'SingleFactorObserved'
        $legacy.SingleFactorSignIns    | Should -Be 1
        $legacy.SingleFactorLegacyAuth | Should -Be 1
        $legacy.SingleFactorClientApps | Should -Contain 'IMAP4'
        $legacy.SingleFactorApps       | Should -Contain 'Office 365 Exchange Online'
        $legacy.LastSingleFactorUtc    | Should -Be ([datetime]'2026-08-06T09:00:00Z')
    }

    It 'filters sign-ins server-side for a small population instead of paging the tenant' {
        # The reason a tenant-wide run looks like a hang: without a userId filter this
        # pages every sign-in in the window. For a handful of users Graph can do the
        # filtering, so the query must actually ask it to.
        $passed = InModuleScope Msec {
            Mock Assert-MsecSession -MockWith { }
            Mock Get-MsecEntraMfaRegistration -MockWith {
                @(
                    [pscustomobject]@{ UserId = 'u1'; UserPrincipalName = 'a@example.com'; UserType = 'member'
                                       IsAdmin = $true; IsMfaCapable = $true; IsMfaRegistered = $true; DefaultMfaMethod = 'push' }
                    [pscustomobject]@{ UserId = 'u2'; UserPrincipalName = 'b@example.com'; UserType = 'member'
                                       IsAdmin = $true; IsMfaCapable = $true; IsMfaRegistered = $true; DefaultMfaMethod = 'push' }
                )
            }
            Mock Get-MsecEntraConditionalAccessPolicy -MockWith { @() }
            Mock Get-MsecEntraConditionalAccessSignInLog -MockWith { @() }

            $null = Get-MsecEntraMfaEvidence -AdminsOnly -WarningAction SilentlyContinue

            # -UserId must be passed, and must carry both ids.
            Should -Invoke Get-MsecEntraConditionalAccessSignInLog -Times 1 -Exactly -ParameterFilter {
                $null -ne $UserId -and @($UserId).Count -eq 2 -and @($UserId) -contains 'u1'
            }
            $true
        }
        $passed | Should -BeTrue
    }

    It 'warns when the window contains no interactive sign-ins at all' {
        # Every row reading NoSignInInWindow is indistinguishable from a broken query,
        # so an empty window says so rather than presenting itself as a finding.
        $result = InModuleScope Msec {
            Mock Assert-MsecSession -MockWith { }
            Mock Get-MsecEntraMfaRegistration -MockWith {
                @([pscustomobject]@{ UserId = 'u1'; UserPrincipalName = 'a@example.com'; UserType = 'member'
                                     IsAdmin = $false; IsMfaCapable = $true; IsMfaRegistered = $true; DefaultMfaMethod = 'push' })
            }
            Mock Get-MsecEntraConditionalAccessPolicy -MockWith { @() }
            # Only a non-interactive sign-in: present in the log, useless as evidence.
            Mock Get-MsecEntraConditionalAccessSignInLog -MockWith {
                @([pscustomobject]@{ UserId = 'u1'; IsInteractive = $false
                                     CreatedDateTime = [datetime]'2026-08-04T09:00:00Z'
                                     MfaSatisfied = $false; MfaFailed = $false; MfaRequiredByPolicies = @() })
            }

            $warnings = @()
            $rows = @(Get-MsecEntraMfaEvidence -WarningVariable warnings -WarningAction SilentlyContinue)
            [pscustomobject]@{ Rows = $rows; Warnings = @($warnings) }
        }

        $result.Rows[0].EvidenceStatus | Should -Be 'NoSignInInWindow'
        ($result.Warnings -join ' ')   | Should -Match 'No INTERACTIVE sign-ins'
    }

    It 'warns and reports every user as unevidenced when sign-in logs cannot be read' {
        $result = InModuleScope Msec {
            Mock Assert-MsecSession -MockWith { }
            Mock Get-MsecEntraMfaRegistration -MockWith {
                @([pscustomobject]@{ UserId = 'u1'; UserPrincipalName = 'u1@example.com'
                                     UserType = 'member'; IsAdmin = $true
                                     IsMfaCapable = $true; IsMfaRegistered = $true; DefaultMfaMethod = 'push' })
            }
            Mock Get-MsecEntraConditionalAccessPolicy -MockWith { @() }
            Mock Get-MsecEntraConditionalAccessSignInLog -MockWith {
                throw 'Sign-in logs are not available in this tenant.'
            }

            $warnings = @()
            $rows = @(Get-MsecEntraMfaEvidence -WarningVariable warnings -WarningAction SilentlyContinue)
            [pscustomobject]@{ Rows = $rows; Warnings = @($warnings) }
        }

        # Capability is still reported, but nothing is claimed about enforcement.
        $result.Rows[0].EvidenceStatus | Should -Be 'NoEvidenceAvailable'
        $result.Rows[0].IsMfaCapable   | Should -BeTrue

        $joined = $result.Warnings -join ' '
        $joined | Should -Match 'NoEvidenceAvailable'
        # And the missing MFA policy is called out as the finding it is.
        $joined | Should -Match 'No ENABLED Conditional Access policy requires MFA'
    }

    It 'warns rather than overstating coverage when an exclusion group cannot be expanded' {
        $result = InModuleScope Msec {
            Mock Assert-MsecSession -MockWith { }
            Mock Get-MsecEntraMfaRegistration -MockWith {
                @([pscustomobject]@{ UserId = 'u1'; UserPrincipalName = 'u1@example.com'
                                     UserType = 'member'; IsAdmin = $false
                                     IsMfaCapable = $true; IsMfaRegistered = $true; DefaultMfaMethod = 'push' })
            }
            Mock Get-MsecEntraConditionalAccessPolicy -MockWith {
                @([pscustomobject]@{ DisplayName = 'Require MFA'; State = 'enabled'; Requires = @('mfa')
                                     IncludedUsers = @('All'); ExcludedUsers = @()
                                     ExcludedGroups = @('g-unreadable'); ExcludedRoles = @() })
            }
            Mock Invoke-MsecGraphRequest -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' }
            Mock Get-MsecEntraConditionalAccessSignInLog -MockWith { @() }

            $warnings = @()
            $rows = @(Get-MsecEntraMfaEvidence -WarningVariable warnings -WarningAction SilentlyContinue)
            [pscustomobject]@{ Rows = $rows; Warnings = @($warnings) }
        }

        # An unreadable exclusion group means anyone inside it is wrongly shown as in
        # scope, which OVERSTATES coverage - the one direction an audit report must not
        # fail in silently.
        ($result.Warnings -join ' ') | Should -Match 'overstates coverage'
        $result.Rows[0].InScopeOfMfaPolicies | Should -Contain 'Require MFA'
    }
}