tests/Get-MsecDefenderEmail.Tests.ps1
|
#Requires -Module Pester # # Tests for Get-MsecDefenderEmail. The function counts matching messages first, then fetches the # newest MaxMessages of them with the sending country resolved as a column. The tests are built # around the ways this command could hand back a confident wrong answer: # # 1. Truncation must warn with BOTH numbers - filtering happens downstream, so a truncated # fetch filtered to one country looks complete and is not. # 2. The count query and the row query must share one filter, or the count describes a # different population from the rows. # 3. -Direction and -ThreatsOnly must reach the KQL; 'All' must NOT emit a direction filter. # 4. IPv6 and missing-IP rows must survive into the output with an explicit SenderCountry # rather than a blank that a -eq filter would silently swallow. # 5. Days must be capped at the 30-day hunting retention rather than silently returning less # than asked for. BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'Msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop } AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'Get-MsecDefenderEmail' { BeforeEach { InModuleScope Msec { $script:MsecSession = @{ TenantId = 'tenant' ClientId = 'client' KeyVaultName = 'kv-test' KeyName = 'msec-app' ThumbprintBytes = [byte[]](1..20) Tokens = @{} } } } It 'warns with the matching total and the returned count when the result is truncated' { $warnings = InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { if ($Body.Query -match '\|\s*count\s*$') { [pscustomobject]@{ Results = @([pscustomobject]@{ Count = 41237 }) } } else { [pscustomobject]@{ Results = @() } } } Get-MsecDefenderEmail -Days 30 -MaxMessages 5000 -WarningVariable w -WarningAction SilentlyContinue | Out-Null $w } $trunc = $warnings | Where-Object { "$_" -match '41237' } $trunc | Should -Not -BeNullOrEmpty "$trunc" | Should -Match '5000' # The part that matters: downstream filtering is over the subset, not the window. "$trunc" | Should -Match 'downstream' } It 'does not warn when everything matching was returned' { $warnings = InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { if ($Body.Query -match '\|\s*count\s*$') { [pscustomobject]@{ Results = @([pscustomobject]@{ Count = 12 }) } } else { [pscustomobject]@{ Results = @() } } } Get-MsecDefenderEmail -MaxMessages 5000 -WarningVariable w -WarningAction SilentlyContinue | Out-Null $w } $warnings | Should -BeNullOrEmpty } It 'applies the SAME filter to the count query and the row query' { $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-MsecDefenderEmail -Days 14 -Direction Inbound -ThreatsOnly -WarningAction SilentlyContinue | Out-Null $script:Queries } @($queries).Count | Should -Be 2 foreach ($q in $queries) { # A count over a different population than the rows is worse than no count at all. $q | Should -Match 'ago\(14d\)' $q | Should -Match 'EmailDirection == "Inbound"' $q | Should -Match 'isnotempty\(ThreatTypes\)' } } It 'emits no direction filter at all for -Direction All' { $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-MsecDefenderEmail -WarningAction SilentlyContinue | Out-Null $script:Queries } foreach ($q in $queries) { $q | Should -Not -Match 'EmailDirection ==' } # ...but direction is still projected, so the caller can filter on it in PowerShell. @($queries)[1] | Should -Match 'EmailDirection' } It 'keeps IPv6 and no-IP messages in the output with an explicit SenderCountry' { $rows = InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { if ($Body.Query -match '\|\s*count\s*$') { [pscustomobject]@{ Results = @([pscustomobject]@{ Count = 2 }) } } else { [pscustomobject]@{ Results = @( [pscustomobject]@{ Timestamp = '2026-10-08T10:00:00Z'; EmailDirection = 'Inbound' SenderCountry = '(IPv6 - not geolocated)'; SenderIPv4 = '' SenderIPv6 = '2a00:1450::1'; SenderFromAddress = 'a@example.com' RecipientEmailAddress = 'anton@contoso.com'; Subject = 'v6' NetworkMessageId = 'nm-1' } [pscustomobject]@{ Timestamp = '2026-10-08T09:00:00Z'; EmailDirection = 'IntraOrg' SenderCountry = '(no sender IP)'; SenderIPv4 = ''; SenderIPv6 = '' SenderFromAddress = 'b@contoso.com'; RecipientEmailAddress = 'anton@contoso.com' Subject = 'internal'; NetworkMessageId = 'nm-2' } ) } } } Get-MsecDefenderEmail -WarningAction SilentlyContinue } @($rows).Count | Should -Be 2 # Not blank. A blank would be swallowed by `Where-Object SenderCountry -eq 'Israel'` # exactly like a real miss, which is the confusion the explicit bucket prevents. $rows[0].SenderCountry | Should -Be '(IPv6 - not geolocated)' $rows[1].SenderCountry | Should -Be '(no sender IP)' $rows[0].Timestamp | Should -BeOfType [datetime] $rows[0].PSObject.TypeNames | Should -Contain 'MsecDefenderEmail' } It 'refuses a window longer than advanced hunting retains' { # Accepting -Days 90 and returning 30 days of mail would be the quiet wrong answer. { Get-MsecDefenderEmail -Days 90 } | Should -Throw } It 'applies every filter parameter server-side, in BOTH the count and the row query' { $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-MsecDefenderEmail -Days 14 -Direction Inbound -SenderCountry 'Israel' ` -RecipientAddress 'anton@contoso.com' -DeliveryLocation 'Inbox' ` -ThreatType Phish -WarningAction SilentlyContinue | Out-Null $script:Queries } @($queries).Count | Should -Be 2 foreach ($q in $queries) { # A count over a different population than the rows is worse than no count at all. $q | Should -Match 'ago\(14d\)' $q | Should -Match 'EmailDirection == "Inbound"' $q | Should -Match 'SenderCountry in~ \("Israel"\)' $q | Should -Match 'RecipientEmailAddress in~ \("anton@contoso.com"\)' $q | Should -Match 'LatestDeliveryLocation in~ \("Inbox"\)' $q | Should -Match 'ThreatTypes has_any \("Phish"\)' # SenderCountry is computed, so the extend must precede the where in both. $q.IndexOf('extend SenderCountry') | Should -BeLessThan $q.IndexOf('SenderCountry in~') } } It 'matches a sender domain against BOTH the header From and the envelope MailFrom' { $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-MsecDefenderEmail -SenderDomain 'example.com' -WarningAction SilentlyContinue | Out-Null @($script:Queries)[0] } # Relayed mail carries different values in the two. Matching one would quietly miss it. $q | Should -Match 'SenderFromDomain in~ \("example\.com"\)' $q | Should -Match 'SenderMailFromDomain in~ \("example\.com"\)' $q | Should -Match '\bor\b' } It 'uses contains for -Subject, not has, so a hyphenated subject still matches' { $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-MsecDefenderEmail -Subject 'invoice', 'payment' -WarningAction SilentlyContinue | Out-Null @($script:Queries)[0] } # `has` is token-based: it finds "Invoice" in "Invoice due" and NOT in "Invoice-2451". $q | Should -Match 'Subject contains "invoice"' $q | Should -Match 'Subject contains "payment"' $q | Should -Not -Match 'Subject has' } It 'escapes quotes and backslashes in 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-MsecDefenderEmail -Subject 'say "hi"' -WarningAction SilentlyContinue | Out-Null @($script:Queries)[0] } # An unescaped quote would terminate the string literal and change what the query means. $q | Should -Match 'Subject contains "say \\"hi\\""' } 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-MsecDefenderEmail -WarningAction SilentlyContinue | Out-Null @($script:Queries)[0] } $q | Should -Not -Match 'EmailDirection ==' $q | Should -Not -Match 'SenderCountry in~' $q | Should -Not -Match 'RecipientEmailAddress in~' $q | Should -Not -Match 'Subject contains' $q | Should -Not -Match 'ThreatTypes has_any' # The window is the one filter that is never optional. $q | Should -Match 'ago\(7d\)' } It 'can ask for the unplaceable buckets by name' { $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-MsecDefenderEmail -SenderCountry '(IPv6 - not geolocated)' -WarningAction SilentlyContinue | Out-Null @($script:Queries)[0] } # The messages that belong to no country must be reachable, not just visible. $q | Should -Match 'SenderCountry in~ \("\(IPv6 - not geolocated\)"\)' } } |