tests/Get-MsecExchangeInboxRule.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecExchangeInboxRule.
#
# This command exists for investigations, where the difference between "no rules" and "could not
# read the rules" decides whether a mailbox is cleared or still suspect. So the traps pinned here
# are all about not manufacturing a clean answer:
#
# A MAILBOX THAT ERRORED MUST PRODUCE A ROW. Skipping it makes an unreadable mailbox look
# identical to a clean one.
#
# ACCEPTED DOMAINS UNREADABLE MEANS ForwardsExternally IS NULL. Saying $false claims the
# forward stays inside the tenant when nothing ever checked.
#
# RECIPIENTS ARE 'Display Name [SMTP:addr]', not bare addresses. Comparing the whole string
# against a domain list matches nothing and reports every forward as internal.

BeforeAll {
    Import-Module (Join-Path $PSScriptRoot '..' 'Msec.psm1') -Force -ErrorAction Stop

    # Only stub what is genuinely missing, and remember what was stubbed, so this file never
    # replaces a stub another test file depends on. Get-EXOMailbox carries the same parameter
    # set as the one in Get-MsecExchangeMailbox.Tests.ps1: a narrower stub winning the race
    # makes that file's -PropertySets call fail to bind, which reads as a broken command.
    $script:StubbedExoCommands = @()
    $exoStubs = @{
        'Get-InboxRule'      = { [CmdletBinding()] param($Mailbox) }
        'Get-AcceptedDomain' = { [CmdletBinding()] param() }
        'Get-EXOMailbox'     = { [CmdletBinding()] param($RecipientTypeDetails, $PropertySets, $ResultSize) }
    }
    foreach ($exoCmd in $exoStubs.Keys) {
        if (-not (Get-Command $exoCmd -ErrorAction SilentlyContinue)) {
            Set-Item "function:global:$exoCmd" -Value $exoStubs[$exoCmd] -Force
            $script:StubbedExoCommands += $exoCmd
        }
    }
}
AfterAll {
    foreach ($exoCmd in $script:StubbedExoCommands) {
        Remove-Item "function:global:$exoCmd" -ErrorAction SilentlyContinue
    }
    Remove-Module msec -Force -ErrorAction SilentlyContinue
}
Describe 'Get-MsecExchangeInboxRule' {

    It 'extracts the SMTP address out of a display-name recipient and calls it external' {
        $rows = InModuleScope msec {
            Mock Initialize-MsecExoSession -MockWith { }
            Mock Get-AcceptedDomain -MockWith { @([pscustomobject]@{ DomainName = 'contoso.com' }) }
            Mock Get-InboxRule -MockWith {
                @([pscustomobject]@{
                    Name = 'Exfil'; Enabled = $true; Priority = 1; IsValid = $true
                    ForwardTo = @('"Bad Person" [SMTP:attacker@evil.example]')
                    Description = 'forward everything'
                })
            }
            Get-MsecExchangeInboxRule -Mailbox 'alice@contoso.com'
        }

        $rows[0].ForwardTo | Should -Be 'attacker@evil.example'
        $rows[0].ForwardsExternally | Should -BeTrue
        $rows[0].ExternalTargets | Should -Be 'attacker@evil.example'
        $rows[0].IsRisky | Should -BeTrue
        $rows[0].RiskReasons | Should -Match 'outside the tenant'
    }

    It 'does not flag a forward that stays inside an accepted domain' {
        $rows = InModuleScope msec {
            Mock Initialize-MsecExoSession -MockWith { }
            Mock Get-AcceptedDomain -MockWith { @([pscustomobject]@{ DomainName = 'contoso.com' }) }
            Mock Get-InboxRule -MockWith {
                @([pscustomobject]@{ Name = 'To my colleague'; Enabled = $true; IsValid = $true
                                     ForwardTo = @('"Bob" [SMTP:bob@contoso.com]') })
            }
            Get-MsecExchangeInboxRule -Mailbox 'alice@contoso.com'
        }

        $rows[0].ForwardsExternally | Should -BeFalse
        $rows[0].IsRisky | Should -BeFalse
    }

    It 'reports ForwardsExternally and IsRisky as null when accepted domains cannot be read' {
        $rows = InModuleScope msec {
            Mock Initialize-MsecExoSession -MockWith { }
            Mock Get-AcceptedDomain -MockWith { throw 'Access denied' }
            Mock Get-InboxRule -MockWith {
                @([pscustomobject]@{ Name = 'Forward'; Enabled = $true; IsValid = $true
                                     ForwardTo = @('"X" [SMTP:x@somewhere.example]') })
            }
            Get-MsecExchangeInboxRule -Mailbox 'alice@contoso.com' -WarningAction SilentlyContinue
        }

        # $false here would be a clean bill of health nothing earned.
        $rows[0].ForwardsExternally | Should -BeNullOrEmpty
        $rows[0].IsRisky | Should -BeNullOrEmpty
        $rows[0].ExternalTargets | Should -BeNullOrEmpty
    }

    It 'produces a row for a mailbox whose rules could not be read' {
        $rows = InModuleScope msec {
            Mock Initialize-MsecExoSession -MockWith { }
            Mock Get-AcceptedDomain -MockWith { @([pscustomobject]@{ DomainName = 'contoso.com' }) }
            Mock Get-InboxRule -MockWith { throw 'The operation could not be performed' }
            Get-MsecExchangeInboxRule -Mailbox 'alice@contoso.com' -WarningAction SilentlyContinue
        }

        $rows.Count | Should -Be 1
        $rows[0].RuleName | Should -Be 'Unreadable'
        $rows[0].Mailbox | Should -Be 'alice@contoso.com'
        $rows[0].IsRisky | Should -BeNullOrEmpty
    }

    It 'emits nothing for a mailbox that genuinely holds no rules' {
        $rows = InModuleScope msec {
            Mock Initialize-MsecExoSession -MockWith { }
            Mock Get-AcceptedDomain -MockWith { @([pscustomobject]@{ DomainName = 'contoso.com' }) }
            Mock Get-InboxRule -MockWith { @() }
            Get-MsecExchangeInboxRule -Mailbox 'alice@contoso.com'
        }

        # A real measurement, and distinct from the Unreadable row above.
        $rows | Should -BeNullOrEmpty
    }

    It 'flags the file-away-and-mark-read pattern even with no forwarding' {
        $rows = InModuleScope msec {
            Mock Initialize-MsecExoSession -MockWith { }
            Mock Get-AcceptedDomain -MockWith { @([pscustomobject]@{ DomainName = 'contoso.com' }) }
            Mock Get-InboxRule -MockWith {
                @([pscustomobject]@{ Name = 'Invoices'; Enabled = $true; IsValid = $true
                                     MoveToFolder = 'RSS Feeds'; MarkAsRead = $true })
            }
            Get-MsecExchangeInboxRule -Mailbox 'alice@contoso.com'
        }

        $rows[0].IsRisky | Should -BeTrue
        $rows[0].RiskReasons | Should -Match 'RSS Feeds'
        $rows[0].ForwardsExternally | Should -BeFalse
    }

    It 'does not flag filing into an ordinary project folder and marking it read' {
        $rows = InModuleScope msec {
            Mock Initialize-MsecExoSession -MockWith { }
            Mock Get-AcceptedDomain -MockWith { @([pscustomobject]@{ DomainName = 'contoso.com' }) }
            Mock Get-InboxRule -MockWith {
                @([pscustomobject]@{ Name = 'Study filing'; Enabled = $true; IsValid = $true
                                     MoveToFolder = 'AML-FLT3 (RJM Group)/2886601'; MarkAsRead = $true })
            }
            Get-MsecExchangeInboxRule -Mailbox 'alice@contoso.com'
        }

        # Measured: flagging any folder fired on 292 of 1373 real rules, nearly all of them
        # ordinary per-study filing. Only a concealing folder counts.
        $rows[0].IsRisky | Should -BeFalse
        $rows[0].MarkAsRead | Should -BeTrue
        $rows[0].RiskReasons | Should -BeNullOrEmpty
    }

    It 'flags an invalid rule' {
        $rows = InModuleScope msec {
            Mock Initialize-MsecExoSession -MockWith { }
            Mock Get-AcceptedDomain -MockWith { @([pscustomobject]@{ DomainName = 'contoso.com' }) }
            Mock Get-InboxRule -MockWith {
                @([pscustomobject]@{ Name = 'Broken'; Enabled = $true; IsValid = $false; ErrorType = 'ParseError' })
            }
            Get-MsecExchangeInboxRule -Mailbox 'alice@contoso.com'
        }

        $rows[0].IsRisky | Should -BeTrue
        $rows[0].RiskReasons | Should -Match 'invalid'
        $rows[0].ErrorType | Should -Be 'ParseError'
    }

    It 'returns only risky rules with -RiskyOnly, and drops ordinary mail management' {
        $rows = InModuleScope msec {
            Mock Initialize-MsecExoSession -MockWith { }
            Mock Get-AcceptedDomain -MockWith { @([pscustomobject]@{ DomainName = 'contoso.com' }) }
            Mock Get-InboxRule -MockWith {
                @(
                    [pscustomobject]@{ Name = 'Project filing'; Enabled = $true; IsValid = $true
                                       MoveToFolder = 'Project X'; StopProcessingRules = $true }
                    [pscustomobject]@{ Name = 'Delete traces'; Enabled = $true; IsValid = $true
                                       DeleteMessage = $true }
                )
            }
            Get-MsecExchangeInboxRule -Mailbox 'alice@contoso.com' -RiskyOnly
        }

        # A plain MoveToFolder plus StopProcessingRules is normal and must not be flagged.
        $rows.Count | Should -Be 1
        $rows[0].RuleName | Should -Be 'Delete traces'
    }
}