tests/UnipharLeaversManagement.Tests.ps1

<#
.SYNOPSIS
    Pester tests for the destructive Entra ID / AD helper functions in UnipharLeaversManagement.
 
.DESCRIPTION
    Covers the cloud-facing leaver functions via mocked Microsoft Graph / Exchange / AD cmdlets:
    Disable-EntraUserAccount, Hide-EntraUserFromGAL, Reset-EntraUserPassword,
    Remove-EntraAuthenticationMethods, plus -WhatIf (ShouldProcess) gating for
    Convert-MailboxToSharedWithOOO and Move-OnPremUserToDisabledOU. These are consumed by the
    Invoke-DisableLeavers runbook.
 
.NOTES
    Author: Security Team
    Requires: Pester v5
#>


BeforeAll {
    $securityAuthPath = Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) 'UnipharSecurityAuth/UnipharSecurityAuth.psm1'
    $leaversPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'UnipharLeaversManagement.psm1'

    # UnipharLeaversManagement depends on New-RandomPassword / Protect-LdapFilterValue.
    Import-Module $securityAuthPath -Force
    Import-Module $leaversPath -Force

    # Stub the external Graph / Exchange / AD cmdlets (global scope) so Mock -ModuleName can
    # resolve them without the real SDKs installed.
    function global:Get-MgUser { param($UserId, $Property) }
    function global:Update-MgUser { param($UserId, $AccountEnabled, $ShowInAddressList, $PasswordProfile, $OnPremisesExtensionAttributes) }
    function global:Get-Mailbox { param($Identity) }
    function global:Set-Mailbox { param($Identity, $Type) }
    function global:Get-ADUser { param($Filter, $Server, $Properties, [PSCredential]$Credential) }
    function global:Set-ADUser { param($Identity, $Replace, $Server, [PSCredential]$Credential) }
    function global:Move-ADObject { param($Identity, $TargetPath, $Server, [PSCredential]$Credential) }
    function global:Get-MgUserAuthenticationPhoneMethod { param($UserId) }
    function global:Remove-MgUserAuthenticationPhoneMethod { param($UserId, $PhoneAuthenticationMethodId) }
    function global:Get-MgUserAuthenticationEmailMethod { param($UserId) }
    function global:Remove-MgUserAuthenticationEmailMethod { param($UserId, $EmailAuthenticationMethodId) }
    function global:Get-MgUserAuthenticationFido2Method { param($UserId) }
    function global:Remove-MgUserAuthenticationFido2Method { param($UserId, $Fido2AuthenticationMethodId) }
    function global:Get-MgUserAuthenticationMicrosoftAuthenticatorMethod { param($UserId) }
    function global:Remove-MgUserAuthenticationMicrosoftAuthenticatorMethod { param($UserId, $MicrosoftAuthenticatorAuthenticationMethodId) }
    function global:Get-MgUserAuthenticationSoftwareOathMethod { param($UserId) }
    function global:Remove-MgUserAuthenticationSoftwareOathMethod { param($UserId, $SoftwareOathAuthenticationMethodId) }
    function global:Get-MgUserAuthenticationTemporaryAccessPassMethod { param($UserId) }
    function global:Remove-MgUserAuthenticationTemporaryAccessPassMethod { param($UserId, $TemporaryAccessPassAuthenticationMethodId) }
    function global:Get-MgUserAuthenticationWindowsHelloForBusinessMethod { param($UserId) }
    function global:Remove-MgUserAuthenticationWindowsHelloForBusinessMethod { param($UserId, $WindowsHelloForBusinessAuthenticationMethodId) }
    function global:Get-MgUserAuthenticationPasswordlessMicrosoftAuthenticatorMethod { param($UserId) }
    function global:Remove-MgUserAuthenticationPasswordlessMicrosoftAuthenticatorMethod { param($UserId, $PasswordlessMicrosoftAuthenticatorAuthenticationMethodId) }
}

AfterAll {
    Get-ChildItem function:global:*-Mg*, function:global:*-Mailbox, function:global:*-ADUser, function:global:Move-ADObject -ErrorAction SilentlyContinue |
        ForEach-Object { Remove-Item "function:global:$($_.Name)" -ErrorAction SilentlyContinue }
    Remove-Module UnipharLeaversManagement -Force -ErrorAction SilentlyContinue
    Remove-Module UnipharSecurityAuth -Force -ErrorAction SilentlyContinue
}

Describe 'Disable-EntraUserAccount' -Tags 'Unit' {
    It 'Disables the account via Update-MgUser with AccountEnabled false' {
        Mock -ModuleName UnipharLeaversManagement Update-MgUser { }
        $result = Disable-EntraUserAccount -UserPrincipalName 'leaver@uniphar.ie'
        $result.Disabled | Should -BeTrue
        Should -Invoke -ModuleName UnipharLeaversManagement Update-MgUser -Times 1 -ParameterFilter {
            $UserId -eq 'leaver@uniphar.ie' -and $AccountEnabled -eq $false
        }
    }

    It 'Reports failure without throwing when Graph errors' {
        Mock -ModuleName UnipharLeaversManagement Update-MgUser { throw 'Graph down' }
        $result = Disable-EntraUserAccount -UserPrincipalName 'leaver@uniphar.ie'
        $result.Disabled | Should -BeFalse
        $result.Error | Should -Match 'Graph down'
    }
}

Describe 'Hide-EntraUserFromGAL' -Tags 'Unit' {
    It 'Hides the user via Update-MgUser with ShowInAddressList false' {
        Mock -ModuleName UnipharLeaversManagement Update-MgUser { }
        $result = Hide-EntraUserFromGAL -UserPrincipalName 'leaver@uniphar.ie'
        $result.HiddenFromGAL | Should -BeTrue
        Should -Invoke -ModuleName UnipharLeaversManagement Update-MgUser -Times 1 -ParameterFilter {
            $ShowInAddressList -eq $false
        }
    }
}

Describe 'Reset-EntraUserPassword' -Tags 'Unit' {
    It 'Sets a 90-character password that does not force change at next sign-in' {
        Mock -ModuleName UnipharLeaversManagement Get-MgUser { [pscustomobject]@{ Id = '00000000-0000-0000-0000-000000000001' } }
        Mock -ModuleName UnipharLeaversManagement Update-MgUser { }
        $result = Reset-EntraUserPassword -UserPrincipalName 'leaver@uniphar.ie'
        $result.PasswordReset | Should -BeTrue
        Should -Invoke -ModuleName UnipharLeaversManagement Update-MgUser -Times 1 -ParameterFilter {
            $PasswordProfile.password.Length -eq 90 -and $PasswordProfile.forceChangePasswordNextSignIn -eq $false
        }
    }

    It 'Returns UserNotFound when the user does not exist' {
        Mock -ModuleName UnipharLeaversManagement Get-MgUser { $null }
        Mock -ModuleName UnipharLeaversManagement Update-MgUser { }
        $result = Reset-EntraUserPassword -UserPrincipalName 'ghost@uniphar.ie'
        $result.PasswordReset | Should -BeFalse
        $result.Error | Should -Be 'UserNotFound'
        Should -Invoke -ModuleName UnipharLeaversManagement Update-MgUser -Times 0
    }
}

Describe 'Remove-EntraAuthenticationMethods' -Tags 'Unit' {
    It 'Removes a registered phone method' {
        Mock -ModuleName UnipharLeaversManagement Get-MgUser { [pscustomobject]@{ Id = '00000000-0000-0000-0000-000000000001' } }
        Mock -ModuleName UnipharLeaversManagement Get-MgUserAuthenticationPhoneMethod {
            @([pscustomobject]@{ Id = 'phone-1'; PhoneType = 'mobile'; PhoneNumber = '+353861234567' })
        }
        Mock -ModuleName UnipharLeaversManagement Remove-MgUserAuthenticationPhoneMethod { }

        $result = Remove-EntraAuthenticationMethods -UserPrincipalName 'leaver@uniphar.ie'

        $result.MethodsRemoved | Should -Contain 'Phone'
        Should -Invoke -ModuleName UnipharLeaversManagement Remove-MgUserAuthenticationPhoneMethod -Times 1 -ParameterFilter {
            $PhoneAuthenticationMethodId -eq 'phone-1'
        }
    }
}

Describe 'Convert-MailboxToSharedWithOOO (ShouldProcess gating)' -Tags 'Unit' {
    It 'Does not call Set-Mailbox under -WhatIf' {
        Mock -ModuleName UnipharLeaversManagement Get-Mailbox { [pscustomobject]@{ RecipientTypeDetails = 'UserMailbox' } }
        Mock -ModuleName UnipharLeaversManagement Set-Mailbox { }
        $result = Convert-MailboxToSharedWithOOO -UserPrincipalName 'leaver@uniphar.ie' -WhatIf
        $result.ConversionResult | Should -Be 'WhatIf-WouldConvert'
        Should -Invoke -ModuleName UnipharLeaversManagement Set-Mailbox -Times 0
    }

    It 'Converts to shared for a normal user mailbox' {
        Mock -ModuleName UnipharLeaversManagement Get-Mailbox { [pscustomobject]@{ RecipientTypeDetails = 'UserMailbox' } }
        Mock -ModuleName UnipharLeaversManagement Set-Mailbox { }
        $result = Convert-MailboxToSharedWithOOO -UserPrincipalName 'leaver@uniphar.ie'
        $result.ConversionResult | Should -Be 'ConvertedToShared'
        Should -Invoke -ModuleName UnipharLeaversManagement Set-Mailbox -Times 1 -ParameterFilter {
            $Type -eq 'Shared'
        }
    }

    It 'Skips conversion when the mailbox is already shared' {
        Mock -ModuleName UnipharLeaversManagement Get-Mailbox { [pscustomobject]@{ RecipientTypeDetails = 'SharedMailbox' } }
        Mock -ModuleName UnipharLeaversManagement Set-Mailbox { }
        $result = Convert-MailboxToSharedWithOOO -UserPrincipalName 'leaver@uniphar.ie'
        $result.ConversionResult | Should -Be 'AlreadyShared'
        Should -Invoke -ModuleName UnipharLeaversManagement Set-Mailbox -Times 0
    }
}

Describe 'Move-OnPremUserToDisabledOU (ShouldProcess gating)' -Tags 'Unit' {
    It 'Does not call Move-ADObject under -WhatIf' {
        Mock -ModuleName UnipharLeaversManagement Get-ADUser {
            [pscustomobject]@{ DistinguishedName = 'CN=Test User,OU=Users,DC=uniphar,DC=local' }
        }
        Mock -ModuleName UnipharLeaversManagement Set-ADUser { }
        Mock -ModuleName UnipharLeaversManagement Move-ADObject { }
        $result = Move-OnPremUserToDisabledOU -UserPrincipalName 'leaver@uniphar.ie' -Server 'dc.uniphar.local' -WhatIf
        $result.Moved | Should -BeFalse
        $result.Error | Should -Be 'WhatIf'
        Should -Invoke -ModuleName UnipharLeaversManagement Move-ADObject -Times 0
    }

    It 'Moves the user to the disabled OU when confirmed' {
        Mock -ModuleName UnipharLeaversManagement Get-ADUser {
            [pscustomobject]@{ DistinguishedName = 'CN=Test User,OU=Users,DC=uniphar,DC=local' }
        }
        Mock -ModuleName UnipharLeaversManagement Set-ADUser { }
        Mock -ModuleName UnipharLeaversManagement Move-ADObject { }
        $result = Move-OnPremUserToDisabledOU -UserPrincipalName 'leaver@uniphar.ie' -Server 'dc.uniphar.local' -DisabledOU 'OU=Disabled,DC=uniphar,DC=local'
        $result.Moved | Should -BeTrue
        Should -Invoke -ModuleName UnipharLeaversManagement Move-ADObject -Times 1 -ParameterFilter {
            $TargetPath -eq 'OU=Disabled,DC=uniphar,DC=local'
        }
    }

    It 'Skips the move when the user is already in the disabled OU' {
        Mock -ModuleName UnipharLeaversManagement Get-ADUser {
            [pscustomobject]@{ DistinguishedName = 'CN=Test User,OU=Disabled,DC=uniphar,DC=local' }
        }
        Mock -ModuleName UnipharLeaversManagement Move-ADObject { }
        $result = Move-OnPremUserToDisabledOU -UserPrincipalName 'leaver@uniphar.ie' -Server 'dc.uniphar.local' -DisabledOU 'OU=Disabled,DC=uniphar,DC=local'
        $result.Error | Should -Be 'AlreadyInDisabledOU'
        Should -Invoke -ModuleName UnipharLeaversManagement Move-ADObject -Times 0
    }
}

Describe 'Merge-LeaverGroupBackupValue (private)' -Tags 'Unit' {
    It 'Tags new group names with the source and merges with existing values' {
        InModuleScope UnipharLeaversManagement {
            $result = Merge-LeaverGroupBackupValue -Existing @('AD:Existing Group') -GroupNames @('Sales', 'Ops') -Source 'AAD'
            $result | Should -Contain 'AD:Existing Group'
            $result | Should -Contain 'AAD:Sales'
            $result | Should -Contain 'AAD:Ops'
        }
    }

    It 'Deduplicates and drops blank or null group names (idempotent re-run)' {
        InModuleScope UnipharLeaversManagement {
            $result = Merge-LeaverGroupBackupValue -Existing @('AD:Ops') -GroupNames @('Ops', '', $null, 'Ops') -Source 'AD'
            @($result).Count | Should -Be 1
            $result | Should -Contain 'AD:Ops'
        }
    }

    It 'Returns plain strings so Set-ADUser -Replace on url does not reject PSObject-wrapped values' {
        InModuleScope UnipharLeaversManagement {
            $result = Merge-LeaverGroupBackupValue -Existing @() -GroupNames @('Sales', 'Ops') -Source 'AAD'
            foreach ($item in $result) { $item.GetType().FullName | Should -Be 'System.String' }
        }
    }
}

Describe 'Merge-LeaverInfoField (private)' -Tags 'Unit' {
    It 'Adds a key line when info is empty' {
        InModuleScope UnipharLeaversManagement {
            Merge-LeaverInfoField -Existing $null -Key 'OriginalOU' -Value 'OU=Users,DC=uniphar,DC=local' |
                Should -Be 'OriginalOU=OU=Users,DC=uniphar,DC=local'
        }
    }

    It 'Replaces the target key and preserves other keys' {
        InModuleScope UnipharLeaversManagement {
            $existing = "OriginalOU=OU=Old,DC=uniphar,DC=local`nLicenses=ENTERPRISEPACK"
            $result = Merge-LeaverInfoField -Existing $existing -Key 'OriginalOU' -Value 'OU=New,DC=uniphar,DC=local'
            $result | Should -Match 'OriginalOU=OU=New,DC=uniphar,DC=local'
            $result | Should -Match 'Licenses=ENTERPRISEPACK'
            @($result -split "`n").Count | Should -Be 2
        }
    }
}