tests/Get-MsecDefenderAlert.Tests.ps1
|
#Requires -Module Pester # # Tests for Get-MsecDefenderIncident and Get-MsecDefenderAlert. # # Three things these commands must not get wrong, all of the same family - a value that means # "no answer yet" must never render as a measurement: # # ResolveDays on an open item is $null, never 0. Zero reads as "closed instantly", which is # the opposite of a still-running investigation. # # A redirected incident was merged into another one. It is the same attack counted twice, so # it has to be visible and droppable - but never dropped silently, or a count that shrank has # no explanation. # # serviceSource 'unknownFutureValue' is Graph saying it has no name for the source, not a # workload called that. It is passed through verbatim rather than guessed at. BeforeAll { Import-Module (Join-Path $PSScriptRoot '..' 'msec.psm1') -Force -ErrorAction Stop } AfterAll { Remove-Module msec -Force -ErrorAction SilentlyContinue } Describe 'Get-MsecDefenderIncident' { BeforeEach { InModuleScope msec { $script:MsecSession = @{ TenantId = 't'; ClientId = 'c'; Tokens = @{} } } } It 'leaves ResolveDays null while an incident is open' { $rows = InModuleScope msec { Mock Invoke-MsecGraphRequest -MockWith { [pscustomobject]@{ id = 'i1'; displayName = 'open'; severity = 'high'; status = 'active' classification = 'unknown' createdDateTime = '2026-09-01T00:00:00Z'; lastUpdateDateTime = '2026-09-10T00:00:00Z' } [pscustomobject]@{ id = 'i2'; displayName = 'done'; severity = 'low'; status = 'resolved' classification = 'unknown' createdDateTime = '2026-09-01T00:00:00Z'; lastUpdateDateTime = '2026-09-03T00:00:00Z' } } Get-MsecDefenderIncident -Days 90 } ($rows | Where-Object Id -eq 'i1').ResolveDays | Should -BeNullOrEmpty ($rows | Where-Object Id -eq 'i2').ResolveDays | Should -Be 2 } It 'returns redirected incidents by default and drops them only when asked' { $result = InModuleScope msec { Mock Invoke-MsecGraphRequest -MockWith { [pscustomobject]@{ id = 'i1'; status = 'active'; severity = 'low'; createdDateTime = '2026-09-01T00:00:00Z' } # Merged into i1 - the same attack, counted twice if this is treated as its own. [pscustomobject]@{ id = 'i2'; status = 'redirected'; severity = 'low'; createdDateTime = '2026-09-01T00:00:00Z' redirectIncidentId = 'i1' } } [pscustomobject]@{ All = @(Get-MsecDefenderIncident -Days 90) Kept = @(Get-MsecDefenderIncident -Days 90 -ExcludeRedirected) } } $all = @($result.All) $kept = @($result.Kept) $all.Count | Should -Be 2 $kept.Count | Should -Be 1 # Visible rather than implied: the row names what absorbed it. ($all | Where-Object Id -eq 'i2').RedirectedToIncidentId | Should -Be 'i1' } It 'reports AlertCount as null when the alerts could not be read, not zero' { $row = InModuleScope msec { Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'incidents\?' } -MockWith { [pscustomobject]@{ id = 'i1'; status = 'active'; severity = 'low'; createdDateTime = '2026-09-01T00:00:00Z' } } Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '/alerts$' } -MockWith { throw 'Forbidden' } Get-MsecDefenderIncident -Days 90 -IncludeAlerts } # An incident whose alerts were refused has not been shown to have none. $row.AlertCount | Should -BeNullOrEmpty } It 'names the permission when the endpoint refuses' { InModuleScope msec { Mock Invoke-MsecGraphRequest -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' } { Get-MsecDefenderIncident } | Should -Throw '*SecurityIncident.Read.All*' } } } Describe 'Get-MsecDefenderAlert' { BeforeEach { InModuleScope msec { $script:MsecSession = @{ TenantId = 't'; ClientId = 'c'; Tokens = @{} } } } It 'passes unknownFutureValue through rather than guessing a workload' { $rows = InModuleScope msec { Mock Invoke-MsecGraphRequest -MockWith { # 231 of 569 alerts on a live tenant came back exactly like this. [pscustomobject]@{ id = 'a1'; title = 'Role grant'; severity = 'high'; status = 'new' serviceSource = 'unknownFutureValue'; productName = 'Microsoft Entra ID' createdDateTime = '2026-09-01T00:00:00Z'; evidence = @(1, 2) } } Get-MsecDefenderAlert -Days 90 } $rows.ServiceSource | Should -Be 'unknownFutureValue' # The fields that are often populated when ServiceSource is not. $rows.ProductName | Should -Be 'Microsoft Entra ID' $rows.EvidenceCount | Should -Be 2 } It 'leaves ResolveDays null while an alert is open' { $rows = InModuleScope msec { Mock Invoke-MsecGraphRequest -MockWith { [pscustomobject]@{ id = 'a1'; status = 'new'; severity = 'low' createdDateTime = '2026-09-01T00:00:00Z' } [pscustomobject]@{ id = 'a2'; status = 'resolved'; severity = 'low' createdDateTime = '2026-09-01T00:00:00Z'; resolvedDateTime = '2026-09-04T00:00:00Z' } } Get-MsecDefenderAlert -Days 90 } ($rows | Where-Object Id -eq 'a1').ResolveDays | Should -BeNullOrEmpty ($rows | Where-Object Id -eq 'a2').ResolveDays | Should -Be 3 } It 'uses the alert status vocabulary, which is not the incident one' { # An alert is never 'active'; an incident never 'new'. Filtering both with one string # silently finds nothing in one of them, so the ValidateSet has to differ. $valid = (Get-Command Get-MsecDefenderAlert).Parameters['Status'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } | Select-Object -ExpandProperty ValidValues $valid | Should -Contain 'new' $valid | Should -Not -Contain 'active' $valid | Should -Not -Contain 'redirected' } } |