Tests/Get-MsecDefenderIncidentStats.Tests.ps1
|
#Requires -Module Pester # # Tests for Get-MsecDefenderIncidentStats. The function fires three independent # Graph queries: # 1. createdDateTime ge {start} -> volume + severity + classification # 2. lastUpdateDateTime ge {start} and status eq 'resolved' -> MTTR # 3. status eq 'active' or status eq 'inProgress' -> backlog # The mock differentiates by $Uri's $filter contents so each call returns its # own canned response. Tests cover the bucketing, MTTR best-practice scoping # (skip FalsePositive), backlog-includes-old behaviour, and the 403 rewrite. BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'Msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop $script:TestThumbBytes = [byte[]](1..20) } AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'Get-MsecDefenderIncidentStats' { BeforeEach { InModuleScope Msec -Parameters @{ Thumb = $script:TestThumbBytes } { param($Thumb) $script:MsecSession = @{ TenantId = 'tenant' ClientId = 'client' KeyVaultName = 'kv-test' KeyName = 'msec-app' ThumbprintBytes = $Thumb Tokens = @{} } } } It 'buckets severity, classification, MTTR (skipping false positives), and backlog correctly' { $out = InModuleScope Msec { Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) } Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith { [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 } } # ---- Query 1: created in window -> volume + severity + classification. # The URI is the raw (unencoded) string built by Invoke-MsecGraphRequest; # mock filters match against THAT, not its URL-encoded form. ---- Mock Invoke-RestMethod ` -ParameterFilter { $Uri -match '/security/incidents' -and $Uri -match 'createdDateTime ge' -and $Uri -notmatch "status eq 'resolved'" } ` -MockWith { [pscustomobject]@{ value = @( # Severity mix [pscustomobject]@{ severity = 'high'; status = 'resolved'; classification = 'truePositive'; createdDateTime = '2026-05-20T08:00:00Z'; lastUpdateDateTime = '2026-05-20T12:00:00Z' } [pscustomobject]@{ severity = 'high'; status = 'active'; classification = $null; createdDateTime = '2026-05-22T08:00:00Z'; lastUpdateDateTime = '2026-05-22T08:00:00Z' } [pscustomobject]@{ severity = 'medium'; status = 'resolved'; classification = 'falsePositive'; createdDateTime = '2026-05-23T08:00:00Z'; lastUpdateDateTime = '2026-05-23T08:15:00Z' } [pscustomobject]@{ severity = 'low'; status = 'resolved'; classification = 'benignPositive'; createdDateTime = '2026-05-24T08:00:00Z'; lastUpdateDateTime = '2026-05-24T20:00:00Z' } [pscustomobject]@{ severity = 'informational'; status = 'resolved'; classification = 'informationalExpectedActivity'; createdDateTime = '2026-05-25T08:00:00Z'; lastUpdateDateTime = '2026-05-25T16:00:00Z' } ) } } # ---- Query 2: resolved in window -> MTTR. Real-world this overlaps with # query 1 in a fresh tenant; here we use the same data shape but only # incidents with status=resolved. Includes a 90-day-old creation date # that resolved in window (to show MTTR over arbitrary creation time). ---- Mock Invoke-RestMethod ` -ParameterFilter { $Uri -match '/security/incidents' -and $Uri -match "status eq 'resolved'" } ` -MockWith { [pscustomobject]@{ value = @( # 4 hours (TP) -> 4 [pscustomobject]@{ classification = 'truePositive'; createdDateTime = '2026-05-20T08:00:00Z'; lastUpdateDateTime = '2026-05-20T12:00:00Z' } # 15 min (FP) -> excluded from MTTR [pscustomobject]@{ classification = 'falsePositive'; createdDateTime = '2026-05-23T08:00:00Z'; lastUpdateDateTime = '2026-05-23T08:15:00Z' } # 12 hours (BP) -> 12 [pscustomobject]@{ classification = 'benignPositive'; createdDateTime = '2026-05-24T08:00:00Z'; lastUpdateDateTime = '2026-05-24T20:00:00Z' } # 8 hours (informationalExpectedActivity treated as BP) -> 8 [pscustomobject]@{ classification = 'informationalExpectedActivity'; createdDateTime = '2026-05-25T08:00:00Z'; lastUpdateDateTime = '2026-05-25T16:00:00Z' } ) } } # ---- Query 3: currently open (active OR inProgress), any age ---- Mock Invoke-RestMethod ` -ParameterFilter { $Uri -match '/security/incidents' -and $Uri -match "status eq 'active'" } ` -MockWith { [pscustomobject]@{ value = @( # 5 days ago - recent [pscustomobject]@{ status = 'active'; createdDateTime = (Get-Date).ToUniversalTime().AddDays(-5).ToString('o') } # 90 days ago - old, but still open. This is the case the # window-based view would MISS, and the reason backlog ignores -Days. [pscustomobject]@{ status = 'inProgress'; createdDateTime = (Get-Date).ToUniversalTime().AddDays(-90).ToString('o') } ) } } Get-MsecDefenderIncidentStats } # ---- Volume in window ---- $out.TotalCreated | Should -Be 5 $out.High | Should -Be 2 $out.Medium | Should -Be 1 $out.Low | Should -Be 1 $out.Informational | Should -Be 1 # Classification: 1 TP, 1 FP, 2 BP (benign + informationalExpectedActivity), 1 unclassified $out.TruePositive | Should -Be 1 $out.FalsePositive | Should -Be 1 $out.BenignPositive | Should -Be 2 $out.Unclassified | Should -Be 1 # ---- Resolution in window ---- # Total resolved = 4 (all of query 2) $out.TotalResolvedInWindow | Should -Be 4 # MTTR over TP+BP only: (4 + 12 + 8) / 3 = 8 hours # FalsePositive (15min = 0.25h) deliberately excluded. $out.MeanTimeToResolveHours | Should -Be 8.0 $out.MedianTimeToResolveHours | Should -Be 8.0 # ...and the denominator is published, so a reader can see the average rests on # 3 of the 4 resolved incidents rather than all of them. $out.ResolvedClassifiedCount | Should -Be 3 # ---- Backlog ---- $out.CurrentlyOpen | Should -Be 2 # Oldest open is ~90 days. Within tolerance of 1 day for clock drift. $out.OldestOpenAgeDays | Should -BeGreaterThan 89 $out.OldestOpenAgeDays | Should -BeLessThan 91 } It 'returns zero counts and null MTTR / OldestOpenAgeDays when no incidents are returned' { $out = InModuleScope Msec { Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) } Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith { [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 } } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/security/incidents' } -MockWith { [pscustomobject]@{ value = @() } } Get-MsecDefenderIncidentStats } $out.TotalCreated | Should -Be 0 $out.High | Should -Be 0 $out.TotalResolvedInWindow | Should -Be 0 $out.ResolvedClassifiedCount | Should -Be 0 $out.MeanTimeToResolveHours | Should -BeNullOrEmpty $out.MedianTimeToResolveHours | Should -BeNullOrEmpty $out.CurrentlyOpen | Should -Be 0 $out.OldestOpenAgeDays | Should -BeNullOrEmpty } # The real-world case that made this field necessary: a team that resolves incidents # but never classifies them gets a null MTTR, which looks identical to a collection # failure. ResolvedClassifiedCount = 0 alongside TotalResolvedInWindow > 0 says # plainly "nothing qualified to be averaged". It 'publishes a zero classified count when incidents are resolved but never classified' { $out = InModuleScope Msec { Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) } Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith { [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 } } # Filters must be mutually exclusive - an overlapping generic mock shadows # the specific ones. Created in window: two incidents, both unclassified. Mock Invoke-RestMethod ` -ParameterFilter { $Uri -match '/security/incidents' -and $Uri -match 'createdDateTime ge' -and $Uri -notmatch "status eq 'resolved'" } ` -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ severity = 'medium'; status = 'resolved'; classification = $null; createdDateTime = '2026-05-20T08:00:00Z'; lastUpdateDateTime = '2026-05-20T12:00:00Z' } [pscustomobject]@{ severity = 'low'; status = 'resolved'; classification = $null; createdDateTime = '2026-05-21T08:00:00Z'; lastUpdateDateTime = '2026-05-22T08:00:00Z' } ) } } # Resolved in window: the same two, closed with NO classification set. Mock Invoke-RestMethod ` -ParameterFilter { $Uri -match '/security/incidents' -and $Uri -match "status eq 'resolved'" } ` -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ classification = $null; createdDateTime = '2026-05-20T08:00:00Z'; lastUpdateDateTime = '2026-05-20T12:00:00Z' } [pscustomobject]@{ classification = $null; createdDateTime = '2026-05-21T08:00:00Z'; lastUpdateDateTime = '2026-05-22T08:00:00Z' } ) } } Mock Invoke-RestMethod ` -ParameterFilter { $Uri -match '/security/incidents' -and $Uri -match "status eq 'active'" } ` -MockWith { [pscustomobject]@{ value = @() } } Get-MsecDefenderIncidentStats } # Work WAS done - two incidents resolved... $out.TotalResolvedInWindow | Should -Be 2 $out.Unclassified | Should -Be 2 # ...but none of it can be timed, and the count says so explicitly rather than # leaving a bare null that looks like a collection failure. $out.ResolvedClassifiedCount | Should -Be 0 -Because 'nothing was classified, so nothing could be averaged' $out.MeanTimeToResolveHours | Should -BeNullOrEmpty $out.MedianTimeToResolveHours | Should -BeNullOrEmpty } It 'rewrites a 403 to mention the missing SecurityIncident.Read.All permission' { InModuleScope Msec { Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) } Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith { [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 } } Mock Invoke-RestMethod -ParameterFilter { $Uri -match '/security/incidents' } -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' } { Get-MsecDefenderIncidentStats } | Should -Throw -ExpectedMessage '*SecurityIncident.Read.All*' } } } |