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'
    }
}

Describe 'Get-MsecDefenderAlert triage selection' {

    BeforeEach {
        InModuleScope msec {
            Mock Assert-MsecSession {}
            $script:AlertQueryPath = $null
            $script:TestAlerts = @(
                @{ id = 'a1'; title = 'Old alert worked last week'; severity = 'low'; status = 'resolved'
                   classification = 'falsePositive'; determination = 'notMalicious'
                   assignedTo = 'analyst@contoso.com'; serviceSource = 'microsoftDefenderForEndpoint'
                   createdDateTime = '2025-01-01T00:00:00Z'
                   evidence = @(
                       @{ '@odata.type' = '#microsoft.graph.security.deviceEvidence'
                          deviceDnsName = 'VIE0001'; verdict = 'suspicious' }
                       @{ '@odata.type' = '#microsoft.graph.security.userEvidence'
                          userAccount = @{ userPrincipalName = 'alex@contoso.com' }; verdict = 'unknown' }
                   ) }
                @{ id = 'a2'; title = 'Suppressed'; severity = 'low'; status = 'resolved'
                   classification = 'falsePositive'; determination = 'notMalicious'
                   assignedTo = 'Suppression rule'; serviceSource = 'microsoftDefenderForEndpoint'
                   createdDateTime = '2026-01-01T00:00:00Z'; evidence = @() }
                @{ id = 'a3'; title = 'Untriaged'; severity = 'high'; status = 'new'
                   classification = 'unknown'; determination = 'unknown'
                   assignedTo = $null; serviceSource = 'microsoftDefenderForEndpoint'
                   createdDateTime = '2026-01-01T00:00:00Z'; evidence = @() }
            )
            Mock Invoke-MsecGraphRequest {
                $script:AlertQueryPath = $Path
                return $script:TestAlerts
            }
        }
    }

    It '-UpdatedWithin queries lastUpdateDateTime instead of createdDateTime' {
        # THE DEFECT THIS CLOSES. -Days finds alerts RAISED in a window, so an alert raised a
        # year ago and triaged last week is absent from it - which made a tenant holding 507
        # classified alerts read as having almost none.
        InModuleScope msec {
            Get-MsecDefenderAlert -UpdatedWithin 45 | Out-Null
            $script:AlertQueryPath | Should -Match 'lastUpdateDateTime ge'
            $script:AlertQueryPath | Should -Not -Match 'createdDateTime'
        }
    }

    It 'defaults to createdDateTime when -UpdatedWithin is not given' {
        InModuleScope msec {
            Get-MsecDefenderAlert -Days 7 | Out-Null
            $script:AlertQueryPath | Should -Match 'createdDateTime ge'
            $script:AlertQueryPath | Should -Not -Match 'lastUpdateDateTime'
        }
    }

    It '-UpdatedWithin REPLACES the created filter rather than ANDing with it' {
        # ANDing would ask for alerts raised AND touched in the window, dropping every old
        # alert worked recently - exactly the set -UpdatedWithin exists to find.
        InModuleScope msec {
            Get-MsecDefenderAlert -Days 7 -UpdatedWithin 45 | Out-Null
            $script:AlertQueryPath | Should -Not -Match 'createdDateTime'
        }
    }

    It '-TriagedBy selects a human analyst and excludes automation' {
        # 'Suppression rule' and 'MdcAlertsResourceProvider' appear in assignedTo on hundreds
        # of alerts. Treating those as verdicts means learning from a suppression rule.
        InModuleScope msec {
            (Get-MsecDefenderAlert -TriagedBy 'analyst@contoso.com').Id | Should -Be @('a1')
        }
    }

    It '-Classification and -Determination filter the labelled set' {
        InModuleScope msec {
            (Get-MsecDefenderAlert -Classification falsePositive).Id | Should -Be @('a1', 'a2')
            (Get-MsecDefenderAlert -Determination notMalicious).Id   | Should -Be @('a1', 'a2')
            # 'unknown' is what an untouched alert carries, so this is the UNtriaged set.
            (Get-MsecDefenderAlert -Classification unknown).Id       | Should -Be @('a3')
        }
    }

    It 'flattens polymorphic evidence to readable identifiers' {
        # Device, user, ip, file, mailbox and url evidence each carry different fields, so a
        # single property cannot be read off them - hence the per-kind resolution.
        InModuleScope msec {
            $ev = (Get-MsecDefenderAlert | Where-Object Id -eq 'a1').Evidence
            $ev | Should -Contain 'device: VIE0001 (suspicious)'
            # verdict 'unknown' adds nothing, so it is left off rather than printed.
            $ev | Should -Contain 'user: alex@contoso.com'
        }
    }

    It 'leaves EvidenceCount and Evidence consistent when there is none' {
        InModuleScope msec {
            $a3 = Get-MsecDefenderAlert | Where-Object Id -eq 'a3'
            $a3.EvidenceCount | Should -Be 0
            @($a3.Evidence).Count | Should -Be 0
        }
    }
}

Describe 'Get-MsecDefenderAlert -AnyAge' {

    BeforeEach {
        InModuleScope msec {
            Mock Assert-MsecSession {}
            $script:AlertQueryPath = $null
            Mock Invoke-MsecGraphRequest {
                $script:AlertQueryPath = $Path
                return @(@{ id = 'a1'; title = 'T'; status = 'new'; severity = 'low'
                            classification = 'unknown'; createdDateTime = '2024-01-01T00:00:00Z'
                            evidence = @() })
            }
        }
    }

    It 'sends no date filter at all' {
        # -Days 365 is NOT equivalent: an alert older than the cap is invisible rather than
        # merely old, which is how an open backlog gets reported as empty.
        InModuleScope msec {
            Get-MsecDefenderAlert -AnyAge | Out-Null
            $script:AlertQueryPath | Should -Not -Match 'filter'
            $script:AlertQueryPath | Should -Not -Match 'createdDateTime'
            $script:AlertQueryPath | Should -Not -Match 'lastUpdateDateTime'
        }
    }

    It 'wins over both -Days and -UpdatedWithin rather than ANDing with them' {
        InModuleScope msec {
            Get-MsecDefenderAlert -AnyAge -Days 7 -UpdatedWithin 30 | Out-Null
            $script:AlertQueryPath | Should -Not -Match 'filter'
        }
    }

    It 'still applies the client-side filters' {
        InModuleScope msec {
            (Get-MsecDefenderAlert -AnyAge -Status new).Id      | Should -Be @('a1')
            (Get-MsecDefenderAlert -AnyAge -Status resolved).Id  | Should -BeNullOrEmpty
        }
    }
}

Describe 'Get-MsecDefenderIncident -AnyAge and attached alerts' {

    BeforeEach {
        InModuleScope msec {
            Mock Assert-MsecSession {}
            $script:IncQueryPath = $null
            Mock Invoke-MsecGraphRequest {
                if ($Path -like '*/alerts*') {
                    return @(@{
                        id = 'al1'; title = 'Malicious blob'; severity = 'high'; status = 'new'
                        classification = 'unknown'; determination = 'unknown'
                        serviceSource = 'microsoftDefenderForCloud'
                        evidence = @(
                            @{ '@odata.type' = '#microsoft.graph.security.userEvidence'
                               userAccount = @{ userPrincipalName = 'alex@contoso.com' } }
                            @{ '@odata.type' = '#microsoft.graph.security.ipEvidence'
                               ipAddress = '203.0.113.9' }
                        )
                    })
                }
                $script:IncQueryPath = $Path
                return @(@{ id = 'i1'; displayName = 'Blob incident'; severity = 'high'
                            status = 'active'; createdDateTime = '2024-01-01T00:00:00Z' })
            }
        }
    }

    It '-AnyAge sends no date filter' {
        InModuleScope msec {
            Get-MsecDefenderIncident -AnyAge | Out-Null
            $script:IncQueryPath | Should -Not -Match 'filter'
        }
    }

    It '-IncludeAlerts attaches the alerts themselves, not just a count' {
        # Without this every caller re-fetches alerts_v2 and joins on IncidentId by hand.
        InModuleScope msec {
            $i = Get-MsecDefenderIncident -AnyAge -IncludeAlerts
            $i.AlertCount        | Should -Be 1
            @($i.Alerts).Count   | Should -Be 1
            $i.Alerts[0].Title   | Should -Be 'Malicious blob'
            $i.Alerts[0].Status  | Should -Be 'new'
        }
    }

    It 'flattens the attached alerts evidence the same way the alert command does' {
        InModuleScope msec {
            $ev = (Get-MsecDefenderIncident -AnyAge -IncludeAlerts).Alerts[0].Evidence
            $ev | Should -Contain 'user: alex@contoso.com'
            $ev | Should -Contain 'ip: 203.0.113.9'
        }
    }

    It 'leaves Alerts empty rather than null when -IncludeAlerts is not used' {
        # Empty and null are different things to a caller; an empty array is safe to iterate.
        InModuleScope msec {
            $i = Get-MsecDefenderIncident -AnyAge
            @($i.Alerts).Count | Should -Be 0
            $i.AlertCount      | Should -BeNullOrEmpty
        }
    }
}