tests/Get-MsecEntraPim.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecEntraPimActivation and Get-MsecEntraPimAlert.
#
# The central trap, and the reason -WithoutMfa exists in the shape it does:
#
# IsAuthenticatedWithMfa IS ABSENT FROM MOST PIM RECORDS. Measured on one tenant over 30
# days: of 118 activations, 40 carried the field as true, 78 did not carry it at all, and
# NONE carried it as false. Defaulting the absence to $false would have reported 78
# privileged activations as unauthenticated - a fabricated finding, and the loudest kind.
# So the property is $null when unrecorded and -WithoutMfa matches $false only.
#
# An unreadable duration is $null, not 0: an activation whose window could not be parsed is
# not one that lasted no time.
#
# A PIM alert that is not firing is a MEASUREMENT, not an absence - so IsActive carries it
# rather than the alert being dropped from the output.

BeforeAll { Import-Module (Join-Path $PSScriptRoot '..' 'Msec.psm1') -Force -ErrorAction Stop }
AfterAll { Remove-Module msec -Force -ErrorAction SilentlyContinue }

Describe 'Get-MsecEntraPimActivation' {
    BeforeEach {
        InModuleScope msec {
            $script:MsecSession = @{ TenantId='t'; ClientId='c'; KeyVaultName='kv'; KeyName='k'
                                     ThumbprintBytes=[byte[]](1..20); Tokens=@{} }
        }
    }

    It 'leaves MFA null when unrecorded, and -WithoutMfa matches only an explicit false' {
        $r = InModuleScope msec {
            Mock Invoke-MsecGraphRequest -MockWith {
                @(
                    [pscustomobject]@{ activityDateTime='2026-10-12T08:58:54Z'; activityDisplayName='Add member to role completed (PIM activation)'
                        category='RoleManagement'; result='success'
                        initiatedBy=[pscustomobject]@{ user=[pscustomobject]@{ userPrincipalName='a@contoso.com' } }
                        targetResources=@([pscustomobject]@{ displayName='Global Administrator' })
                        additionalDetails=@(
                            [pscustomobject]@{ key='AuditType'; value='ActivateRole' }
                            [pscustomobject]@{ key='IsAuthenticatedWithMfa'; value='True' }
                            [pscustomobject]@{ key='Justification'; value='sec work' }) }
                    [pscustomobject]@{ activityDateTime='2026-10-12T08:27:41Z'; activityDisplayName='Add member to role completed (PIM activation)'
                        category='ResourceManagement'; result='success'
                        initiatedBy=[pscustomobject]@{ user=[pscustomobject]@{ userPrincipalName='b@contoso.com' } }
                        targetResources=@([pscustomobject]@{ displayName='Contributor' })
                        # No IsAuthenticatedWithMfa at all - the common case.
                        additionalDetails=@([pscustomobject]@{ key='AuditType'; value='ActivateRole' }) }
                    [pscustomobject]@{ activityDateTime='2026-10-11T08:00:00Z'; activityDisplayName='Add member to role completed (PIM activation)'
                        category='RoleManagement'; result='success'
                        initiatedBy=[pscustomobject]@{ user=[pscustomobject]@{ userPrincipalName='c@contoso.com' } }
                        targetResources=@([pscustomobject]@{ displayName='User Administrator' })
                        additionalDetails=@(
                            [pscustomobject]@{ key='AuditType'; value='ActivateRole' }
                            [pscustomobject]@{ key='IsAuthenticatedWithMfa'; value='False' }) }
                )
            }
            [pscustomobject]@{
                All        = @(Get-MsecEntraPimActivation -Days 30)
                WithoutMfa = @(Get-MsecEntraPimActivation -Days 30 -WithoutMfa)
            }
        }

        $r.All.Count | Should -Be 3
        ($r.All | Where-Object Actor -eq 'a@contoso.com').AuthenticatedWithMfa | Should -BeTrue
        # NOT $false. Absent means "not recorded on this record type".
        ($r.All | Where-Object Actor -eq 'b@contoso.com').AuthenticatedWithMfa | Should -BeNullOrEmpty
        ($r.All | Where-Object Actor -eq 'c@contoso.com').AuthenticatedWithMfa | Should -Be $false

        # The whole point: only the explicit false. Counting the unrecorded one here would
        # manufacture a privileged-access finding out of a missing field.
        $r.WithoutMfa.Count  | Should -Be 1
        $r.WithoutMfa.Actor  | Should -Be 'c@contoso.com'
    }

    It 'computes duration from the activation window, and leaves it null when either end is missing' {
        $rows = InModuleScope msec {
            Mock Invoke-MsecGraphRequest -MockWith {
                @(
                    [pscustomobject]@{ activityDateTime='2026-10-12T08:00:00Z'; category='RoleManagement'
                        targetResources=@([pscustomobject]@{ displayName='Global Administrator' })
                        initiatedBy=[pscustomobject]@{ user=[pscustomobject]@{ userPrincipalName='a@x' } }
                        additionalDetails=@(
                            [pscustomobject]@{ key='AuditType'; value='ActivateRole' }
                            [pscustomobject]@{ key='StartTime'; value='2026-10-12T08:00:00Z' }
                            [pscustomobject]@{ key='ExpirationTime'; value='2026-10-12T16:00:00Z' }) }
                    [pscustomobject]@{ activityDateTime='2026-10-12T09:00:00Z'; category='RoleManagement'
                        targetResources=@([pscustomobject]@{ displayName='User Administrator' })
                        initiatedBy=[pscustomobject]@{ user=[pscustomobject]@{ userPrincipalName='b@x' } }
                        additionalDetails=@([pscustomobject]@{ key='AuditType'; value='ActivateRole' }) }
                )
            }
            @(Get-MsecEntraPimActivation -Days 30)
        }

        ($rows | Where-Object Actor -eq 'a@x').DurationHours | Should -Be 8
        # $null, not 0 - a window that could not be read is not a zero-length activation.
        ($rows | Where-Object Actor -eq 'b@x').DurationHours | Should -BeNullOrEmpty
    }

    It 'keeps both PIM scopes and narrows to activations only on request' {
        $r = InModuleScope msec {
            Mock Invoke-MsecGraphRequest -MockWith {
                @(
                    [pscustomobject]@{ activityDateTime='2026-10-12T08:00:00Z'; category='RoleManagement'
                        targetResources=@([pscustomobject]@{ displayName='Global Administrator' })
                        initiatedBy=[pscustomobject]@{ user=[pscustomobject]@{ userPrincipalName='a@x' } }
                        additionalDetails=@([pscustomobject]@{ key='AuditType'; value='ActivateRole' }) }
                    [pscustomobject]@{ activityDateTime='2026-10-12T08:10:00Z'; category='ResourceManagement'
                        targetResources=@([pscustomobject]@{ displayName='Contributor' })
                        initiatedBy=[pscustomobject]@{ user=[pscustomobject]@{ userPrincipalName='b@x' } }
                        additionalDetails=@([pscustomobject]@{ key='AuditType'; value='ActivateRole' }) }
                    [pscustomobject]@{ activityDateTime='2026-10-12T08:20:00Z'; category='RoleManagement'
                        targetResources=@([pscustomobject]@{ displayName='Alert' })
                        initiatedBy=[pscustomobject]@{ user=[pscustomobject]@{ userPrincipalName=$null } }
                        additionalDetails=@([pscustomobject]@{ key='AuditType'; value='ResolveAlert' }) }
                )
            }
            [pscustomobject]@{
                All         = @(Get-MsecEntraPimActivation -Days 30)
                Activations = @(Get-MsecEntraPimActivation -Days 30 -ActivationsOnly)
            }
        }

        # Azure resource PIM logs here too - dropping it would halve the picture.
        $r.All.Count | Should -Be 3
        @($r.All | Where-Object Scope -eq 'ResourceManagement').Count | Should -Be 1
        $r.Activations.Count | Should -Be 2
        @($r.Activations | Where-Object AuditType -eq 'ResolveAlert').Count | Should -Be 0
    }

    It 'refuses a window longer than the directory audit log retains' {
        { Get-MsecEntraPimActivation -Days 60 } | Should -Throw
    }
}

Describe 'Get-MsecEntraPimAlert' {
    BeforeEach {
        InModuleScope msec {
            $script:MsecSession = @{ TenantId='t'; ClientId='c'; KeyVaultName='kv'; KeyName='k'
                                     ThumbprintBytes=[byte[]](1..20); Tokens=@{} }
        }
    }

    It 'returns alerts that are not firing, because quiet is a measurement' {
        $r = InModuleScope msec {
            Mock Invoke-MsecGraphRequest -MockWith {
                @(
                    [pscustomobject]@{ id='a1'; isActive=$true; incidentCount=104; scopeType='DirectoryRole'
                        alertDefinition=[pscustomobject]@{ id='d1'; displayName='Roles are being assigned outside of Privileged Identity Management'
                                                           severityLevel='medium'; description='...' } }
                    [pscustomobject]@{ id='a2'; isActive=$false; incidentCount=0; scopeType='DirectoryRole'
                        alertDefinition=[pscustomobject]@{ id='d2'; displayName='There are too many global administrators'
                                                           severityLevel='low'; description='...' } }
                )
            }
            [pscustomobject]@{
                All    = @(Get-MsecEntraPimAlert)
                Active = @(Get-MsecEntraPimAlert -ActiveOnly)
            }
        }

        # Both returned by default: "PIM scanned and found nothing" and "PIM does not scan for
        # this" must not look identical, which dropping the quiet one would cause.
        $r.All.Count    | Should -Be 2
        $r.Active.Count | Should -Be 1
        $r.Active.AlertName | Should -Match 'outside of Privileged Identity Management'
        $r.Active.IncidentCount | Should -Be 104
    }

    It 'rewrites a 403 to say the Global Reader role does not cover this endpoint' {
        InModuleScope msec {
            Mock Invoke-MsecGraphRequest -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' }
            { Get-MsecEntraPimAlert } | Should -Throw '*RoleManagementAlert.Read.Directory*'
            { Get-MsecEntraPimAlert } | Should -Throw '*Global Reader*'
        }
    }

    It 'names a beta-endpoint failure as an API change rather than an empty tenant' {
        InModuleScope msec {
            Mock Invoke-MsecGraphRequest -MockWith { throw 'Response status code does not indicate success: 404 (Not Found).' }
            { Get-MsecEntraPimAlert } | Should -Throw '*BETA*'
        }
    }
}