tests/Set-MsecDefenderIncident.Tests.ps1
|
#Requires -Module Pester # # Tests for Set-MsecDefenderIncident. The guards are the same family as Set-MsecDefenderAlert's, # plus two that are specific to incidents: # # -CustomTags REPLACES the tag array. Graph has no append for a collection property, so the # command must say so before dropping existing tags rather than after. # # 'redirected' must not be settable. Defender assigns it when it merges an incident into # another; offering it as a status would imply this command can merge incidents, which it # cannot. # # And the reason this command exists at all: the resolution comment lives on the incident, # because Graph has no writable comment on an alert. BeforeAll { Import-Module (Join-Path $PSScriptRoot '..' 'msec.psm1') -Force -ErrorAction Stop function New-AdminSession { [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' GrantedScope = @('SecurityIncident.ReadWrite.All') } } } AfterAll { Remove-Module msec -Force -ErrorAction SilentlyContinue } Describe 'Set-MsecDefenderIncident' { It 'refuses when only the read-only app session exists' { InModuleScope msec { $script:MsecSession = @{ TenantId = 't'; ClientId = 'c'; Tokens = @{} } $script:MsecAdminSession = $null { Set-MsecDefenderIncident -Id '1' -Status resolved -Confirm:$false } | Should -Throw '*Connect-MsecAdmin*' } } It 'writes every incident piped in - there is no cap' { InModuleScope msec { $script:MsecAdminSession = [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' GrantedScope = @('SecurityIncident.ReadWrite.All') } Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me'; TenantId = 't' } } Mock Start-Sleep -MockWith { } Mock Invoke-MsecAdminGraphRequest -MockWith { @{ id = '1'; status = 'resolved' } } $rows = @(1..40 | Set-MsecDefenderIncident -Status resolved -Confirm:$false) $rows.Count | Should -Be 40 } } It 'sends the resolving comment as resolvingComment' { $sent = InModuleScope msec { $script:MsecAdminSession = [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' GrantedScope = @('SecurityIncident.ReadWrite.All') } Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me'; TenantId = 't' } } Mock Start-Sleep -MockWith { } $script:captured = $null Mock Invoke-MsecAdminGraphRequest -MockWith { if ($Method -eq 'PATCH') { $script:captured = $Body } @{ id = '1'; displayName = 'D'; severity = 'low'; status = 'resolved' resolvingComment = 'Authorised pen test' } } $null = '1' | Set-MsecDefenderIncident -Status resolved ` -ResolvingComment 'Authorised pen test' -Confirm:$false $script:captured } $sent['resolvingComment'] | Should -Be 'Authorised pen test' $sent['status'] | Should -Be 'resolved' } It 'reports the comment that came back on re-read' { $row = InModuleScope msec { $script:MsecAdminSession = [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' GrantedScope = @('SecurityIncident.ReadWrite.All') } Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me'; TenantId = 't' } } Mock Start-Sleep -MockWith { } $script:n = 0 Mock Invoke-MsecAdminGraphRequest -MockWith { $script:n++ if ($script:n -eq 1) { @{ id = '1'; displayName = 'D'; severity = 'high'; status = 'active' } } else { @{ id = '1'; displayName = 'D'; severity = 'high'; status = 'resolved' resolvingComment = 'Closed - benign' } } } '1' | Set-MsecDefenderIncident -Status resolved -ResolvingComment 'Closed - benign' -Confirm:$false } $row.StatusBefore | Should -Be 'active' $row.StatusAfter | Should -Be 'resolved' $row.ResolvingCommentAfter | Should -Be 'Closed - benign' $row.Changed | Should -BeTrue } It 'warns before -CustomTags drops the tags already on the incident' { $warnings = InModuleScope msec { $script:MsecAdminSession = [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' GrantedScope = @('SecurityIncident.ReadWrite.All') } Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me'; TenantId = 't' } } Mock Start-Sleep -MockWith { } Mock Invoke-MsecAdminGraphRequest -MockWith { @{ id = '1'; displayName = 'D'; severity = 'low'; status = 'active' customTags = @('KeepMe', 'AndMe') } } $null = '1' | Set-MsecDefenderIncident -CustomTags 'Replacement' -Confirm:$false ` -WarningVariable w -WarningAction SilentlyContinue $w } ($warnings -join ' ') | Should -Match 'replaces rather than appends' ($warnings -join ' ') | Should -Match 'KeepMe' } It 'does not offer redirected as a settable status' { $values = (Get-Command Set-MsecDefenderIncident).Parameters['Status'].Attributes. Where({ $_ -is [ValidateSet] }).ValidValues $values | Should -Not -Contain 'redirected' $values | Should -Contain 'resolved' $values | Should -Contain 'active' } It 'offers the statuses that are in $metadata but missing from the docs' { $values = (Get-Command Set-MsecDefenderIncident).Parameters['Status'].Attributes. Where({ $_ -is [ValidateSet] }).ValidValues $values | Should -Contain 'inProgress' $values | Should -Contain 'awaitingAction' } It 'is the only place a note can be written - alerts have no comment at all' { # Graph has no writable comment on an alert. Set-MsecDefenderAlert briefly had a -Comment # that routed to the Defender for Endpoint API, which covered 29 of 569 alerts on the # measured tenant and put a second user identity inside one command. It was removed, so # resolvingComment on the incident is now the single route for a resolution note. (Get-Command Set-MsecDefenderIncident).Parameters.Keys | Should -Contain 'ResolvingComment' (Get-Command Set-MsecDefenderAlert).Parameters.Keys | Should -Not -Contain 'Comment' (Get-Command Set-MsecDefenderAlert).Parameters.Keys | Should -Not -Contain 'ResolvingComment' } } |