tests/Get-MsecDefenderTeamsMessage.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecDefenderTeamsMessage. The function counts matching messages, then fetches
# them with recipients flattened out of a JSON array and URL info joined from a second table.
# The tests are built around what this command can get quietly wrong:
#
# 1. A leftouter join that misses returns an EMPTY PSCustomObject, not $null. [int] on it
# throws and [bool] on it returns $true - so a missing IsExternalThread would read as
# external and a message with no links would crash the projection.
# 2. Recipients are an array on ONE row per message, not one row per recipient like mail.
# They must stay a string[] so -contains tests them exactly.
# 3. Filters must reach both queries, and the count query must NOT carry the mv-apply or the
# join - it counts the same population by filtering the same raw columns.
# 4. -Subject and -ThreadName must use contains, not the token-based has.
# 5. Days must be capped at the 30-day hunting retention.

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

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

Describe 'Get-MsecDefenderTeamsMessage' {
    BeforeEach {
        InModuleScope Msec {
            $script:MsecSession = @{
                TenantId        = 'tenant'
                ClientId        = 'client'
                KeyVaultName    = 'kv-test'
                KeyName         = 'msec-app'
                ThumbprintBytes = [byte[]](1..20)
                Tokens          = @{}
            }
        }
    }

    It 'survives a leftouter join miss, where the API returns an empty object rather than null' {
        $row = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -MockWith {
                if ($Body.Query -match '\|\s*count\s*$') {
                    [pscustomobject]@{ Results = @([pscustomobject]@{ Count = 1 }) }
                }
                else {
                    [pscustomobject]@{ Results = @([pscustomobject]@{
                        Timestamp = '2026-10-09T08:12:15Z'; ThreadType = 'chat'; ThreadName = 'Product Team'
                        # Exactly what Kusto hands back for a join that found nothing, and for a
                        # bool column that was absent: an empty object, NOT $null.
                        UrlCount = [pscustomobject]@{}; UrlDomains = [pscustomobject]@{}
                        IsExternalThread = [pscustomobject]@{}; IsOwnedThread = '1'
                        SenderEmailAddress = 'a@contoso.com'
                        RecipientAddress = @('b@contoso.com', 'c@contoso.com')
                        LastEditedTime = [pscustomobject]@{}
                    }) }
                }
            }
            Get-MsecDefenderTeamsMessage -WarningAction SilentlyContinue
        }

        # No URL row found means zero links, not a crash.
        $row.UrlCount   | Should -Be 0
        $row.UrlCount   | Should -BeOfType [int]
        @($row.UrlDomains).Count | Should -Be 0
        # The one that matters most: [bool] on a non-null object is $true, which would mark an
        # internal thread as crossing the tenant boundary.
        $row.IsExternalThread | Should -BeFalse
        $row.IsOwnedThread    | Should -BeTrue
        $row.LastEditedTime   | Should -BeNullOrEmpty
    }

    It 'keeps recipients as a string[] on one row per message, so -contains tests them exactly' {
        $row = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -MockWith {
                if ($Body.Query -match '\|\s*count\s*$') {
                    [pscustomobject]@{ Results = @([pscustomobject]@{ Count = 1 }) }
                }
                else {
                    [pscustomobject]@{ Results = @([pscustomobject]@{
                        Timestamp = '2026-10-09T08:12:15Z'
                        RecipientAddress = @('alex.diaz@contoso.com', 'jordan.lee@contoso.com', '')
                        UrlCount = 2; UrlDomains = @('teams.microsoft.com', '')
                    }) }
                }
            }
            Get-MsecDefenderTeamsMessage -WarningAction SilentlyContinue
        }

        # Empty entries dropped - make_list can emit one for a recipient with no SMTP address,
        # and a blank in the array makes `-contains ''` true for every message.
        @($row.RecipientAddress).Count | Should -Be 2
        $row.RecipientAddress | Should -Contain 'jordan.lee@contoso.com'
        @($row.UrlDomains).Count | Should -Be 1

        # The flattening must happen in KQL, not by joining into a string: a substring match
        # would report 'anna@x' as a hit for 'joanna@x'.
        $row.RecipientAddress -is [array] | Should -BeTrue
    }

    It 'applies filters to both queries, and keeps the mv-apply and join out of the count' {
        $queries = InModuleScope Msec {
            $script:Queries = [System.Collections.Generic.List[string]]::new()
            Mock Invoke-MsecGraphRequest -MockWith {
                $script:Queries.Add($Body.Query)
                [pscustomobject]@{ Results = @([pscustomobject]@{ Count = 1 }) }
            }
            Get-MsecDefenderTeamsMessage -Days 14 -ExternalOnly -ThreadType chat `
                -SenderType Anonymous -ThreatType Phish -WarningAction SilentlyContinue | Out-Null
            $script:Queries
        }

        @($queries).Count | Should -Be 2
        foreach ($q in $queries) {
            $q | Should -Match 'ago\(14d\)'
            $q | Should -Match 'IsExternalThread'
            $q | Should -Match 'ThreadType in~ \("chat"\)'
            $q | Should -Match 'SenderType in~ \("Anonymous"\)'
            $q | Should -Match 'ThreatTypes has_any \("Phish"\)'
        }

        # The count filters the same raw columns, so it needs neither the flatten nor the join -
        # and must not pay for them.
        @($queries)[0] | Should -Not -Match 'mv-apply'
        @($queries)[0] | Should -Not -Match 'MessageUrlInfo'
        @($queries)[1] | Should -Match 'mv-apply'
        @($queries)[1] | Should -Match 'MessageUrlInfo'
    }

    It 'matches recipients against the raw JSON array, since there is no recipient column' {
        $q = InModuleScope Msec {
            $script:Queries = [System.Collections.Generic.List[string]]::new()
            Mock Invoke-MsecGraphRequest -MockWith {
                $script:Queries.Add($Body.Query)
                [pscustomobject]@{ Results = @([pscustomobject]@{ Count = 1 }) }
            }
            Get-MsecDefenderTeamsMessage -RecipientAddress 'anton@contoso.com' -WarningAction SilentlyContinue | Out-Null
            @($script:Queries)[0]
        }

        $q | Should -Match 'tostring\(RecipientDetails\) contains "anton@contoso\.com"'
    }

    It 'uses contains rather than the token-based has for -Subject and -ThreadName' {
        $q = InModuleScope Msec {
            $script:Queries = [System.Collections.Generic.List[string]]::new()
            Mock Invoke-MsecGraphRequest -MockWith {
                $script:Queries.Add($Body.Query)
                [pscustomobject]@{ Results = @([pscustomobject]@{ Count = 1 }) }
            }
            Get-MsecDefenderTeamsMessage -Subject 'invoice' -ThreadName 'SMS Teknik' -WarningAction SilentlyContinue | Out-Null
            @($script:Queries)[0]
        }

        # `has` is token-based: 'SMS Teknik' as a thread name and 'Invoice-2451' as a subject
        # both fail it, and a hyphen is exactly where real names live.
        $q | Should -Match 'Subject contains "invoice"'
        $q | Should -Match 'ThreadName contains "SMS Teknik"'
        $q | Should -Not -Match 'Subject has'
        $q | Should -Not -Match 'ThreadName has'
    }

    It 'escapes a caller-supplied value instead of breaking the KQL' {
        $q = InModuleScope Msec {
            $script:Queries = [System.Collections.Generic.List[string]]::new()
            Mock Invoke-MsecGraphRequest -MockWith {
                $script:Queries.Add($Body.Query)
                [pscustomobject]@{ Results = @([pscustomobject]@{ Count = 1 }) }
            }
            Get-MsecDefenderTeamsMessage -ThreadName 'say "hi"' -WarningAction SilentlyContinue | Out-Null
            @($script:Queries)[0]
        }

        $q | Should -Match 'ThreadName contains "say \\"hi\\""'
    }

    It 'warns with both numbers when the result is truncated' {
        $warnings = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -MockWith {
                if ($Body.Query -match '\|\s*count\s*$') {
                    [pscustomobject]@{ Results = @([pscustomobject]@{ Count = 1875 }) }
                }
                else { [pscustomobject]@{ Results = @() } }
            }
            Get-MsecDefenderTeamsMessage -MaxMessages 5 -WarningVariable w -WarningAction SilentlyContinue | Out-Null
            $w
        }

        $t = $warnings | Where-Object { "$_" -match '1875' }
        $t | Should -Not -BeNullOrEmpty
        "$t" | Should -Match '\b5\b'
    }

    It 'sends no filter clause for a parameter that was not supplied' {
        $q = InModuleScope Msec {
            $script:Queries = [System.Collections.Generic.List[string]]::new()
            Mock Invoke-MsecGraphRequest -MockWith {
                $script:Queries.Add($Body.Query)
                [pscustomobject]@{ Results = @([pscustomobject]@{ Count = 1 }) }
            }
            Get-MsecDefenderTeamsMessage -WarningAction SilentlyContinue | Out-Null
            @($script:Queries)[0]
        }

        $q | Should -Not -Match 'ThreadType in~'
        $q | Should -Not -Match 'SenderType in~'
        $q | Should -Not -Match 'Subject contains'
        $q | Should -Not -Match 'IsExternalThread'
        $q | Should -Match 'ago\(7d\)'
    }

    It 'refuses a window longer than advanced hunting retains' {
        { Get-MsecDefenderTeamsMessage -Days 90 } | Should -Throw
    }
}