tests/Set-MsecSentinelIncident.Tests.ps1
|
#Requires -Module Pester # # Tests for Set-MsecSentinelIncident. # # The traps: # # THE API HAS NO PATCH. PUT REPLACES THE RESOURCE, so a body carrying only the changed # fields silently discards title, description, labels and owner. The command GETs first and # puts the existing properties back alongside the change. # # THE ETAG IS CONCURRENCY CONTROL. Sent as If-Match so a concurrent edit fails the write # rather than being overwritten. # # A CLASSIFICATION NEEDS A REASON, EXCEPT Undetermined WHICH MUST NOT HAVE ONE. BeforeAll { Import-Module (Join-Path $PSScriptRoot '..' 'Msec.psm1') -Force -ErrorAction Stop } AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'Set-MsecSentinelIncident' { BeforeEach { InModuleScope Msec { Mock Resolve-MsecSentinelWorkspace -MockWith { [pscustomobject]@{ Headers = @{ Authorization = 'Bearer t' } Subscription = '00000000-0000-0000-0000-000000000000' Workspaces = @([pscustomobject]@{ Name = 'contoso-sentinel'; ResourceGroupName = 'rg' ResourceId = '/x'; Base = 'https://management.azure.com/x/providers/Microsoft.SecurityInsights' }) } } Mock Get-MsecSentinelIncident -MockWith { [pscustomobject]@{ Number = 51; Name = 'guid-51'; Status = 'New' } } } } It 'refuses a classification with no reason, naming the rule' { InModuleScope Msec { { Set-MsecSentinelIncident -Number 51 -WorkspaceName contoso-sentinel -Status Closed ` -Classification BenignPositive -Confirm:$false } | Should -Throw '*requires -ClassificationReason*' } } It 'refuses Undetermined WITH a reason, which the API rejects' { InModuleScope Msec { { Set-MsecSentinelIncident -Number 51 -WorkspaceName contoso-sentinel -Status Closed ` -Classification Undetermined -ClassificationReason InaccurateData -Confirm:$false } | Should -Throw '*must NOT carry*' } } It 'refuses a classification unless the incident is being closed' { InModuleScope Msec { { Set-MsecSentinelIncident -Number 51 -WorkspaceName contoso-sentinel ` -Classification TruePositive -ClassificationReason SuspiciousActivity -Confirm:$false } | Should -Throw '*only to a closed incident*' } } It 'refuses a call that changes nothing' { InModuleScope Msec { { Set-MsecSentinelIncident -Number 51 -WorkspaceName contoso-sentinel -Confirm:$false } | Should -Throw '*Nothing to change*' } } It 'carries the existing properties into the PUT, because PUT replaces' { # THE BUG THIS PREVENTS. A body with only status would wipe title, description and # labels off the incident - data the caller never read and did not mean to touch. $body = InModuleScope Msec { Mock Invoke-RestMethod -ParameterFilter { $Method -eq 'Put' } -MockWith { $script:SeenBody = $Body @{ properties = @{ status = 'Closed' } } } Mock Invoke-RestMethod -MockWith { @{ name = 'guid-51'; etag = 'etag-abc' properties = @{ incidentNumber = 51; title = 'Original title'; description = 'Original description' status = 'New'; severity = 'High'; labels = @('keepme'); providerName = 'Azure Sentinel' } } } $null = Set-MsecSentinelIncident -Number 51 -WorkspaceName contoso-sentinel -Status Closed ` -Classification BenignPositive -ClassificationReason SuspiciousButExpected -Confirm:$false $script:SeenBody } $body | Should -BeLike '*Original title*' $body | Should -BeLike '*Original description*' $body | Should -BeLike '*keepme*' $body | Should -BeLike '*Closed*' } It 'sends the etag as If-Match, so a concurrent edit is not overwritten' { $hdr = InModuleScope Msec { Mock Invoke-RestMethod -ParameterFilter { $Method -eq 'Put' } -MockWith { $script:SeenHeaders = $Headers @{ properties = @{ status = 'Closed' } } } Mock Invoke-RestMethod -MockWith { @{ name = 'guid-51'; etag = 'etag-abc' properties = @{ incidentNumber = 51; title = 't'; status = 'New'; severity = 'High'; providerName = 'Azure Sentinel' } } } $null = Set-MsecSentinelIncident -Number 51 -WorkspaceName contoso-sentinel -Status Closed ` -Classification BenignPositive -ClassificationReason SuspiciousButExpected -Confirm:$false $script:SeenHeaders } $hdr['If-Match'] | Should -Be 'etag-abc' } It 'explains a 412 as a concurrent change rather than a permission problem' { $msg = InModuleScope Msec { Mock Invoke-RestMethod -ParameterFilter { $Method -eq 'Put' } -MockWith { throw 'Response status code does not indicate success: 412 (PreconditionFailed).' } Mock Invoke-RestMethod -MockWith { @{ name = 'guid-51'; etag = 'etag-abc' properties = @{ incidentNumber = 51; title = 't'; status = 'New'; severity = 'High'; providerName = 'Azure Sentinel' } } } try { Set-MsecSentinelIncident -Number 51 -WorkspaceName contoso-sentinel -Status Closed ` -Classification BenignPositive -ClassificationReason SuspiciousButExpected -Confirm:$false | Out-Null } catch { $_.Exception.Message } } $msg | Should -BeLike '*changed between the read and the write*' $msg | Should -BeLike '*nothing was written*' } It 'writes nothing under -WhatIf' { InModuleScope Msec { Mock Invoke-RestMethod -ParameterFilter { $Method -eq 'Put' } -MockWith { @{ properties = @{} } } Mock Invoke-RestMethod -MockWith { @{ name = 'guid-51'; etag = 'e' properties = @{ incidentNumber = 51; title = 't'; status = 'New'; severity = 'High'; providerName = 'Azure Sentinel' } } } $null = Set-MsecSentinelIncident -Number 51 -WorkspaceName contoso-sentinel -Status Closed ` -Classification BenignPositive -ClassificationReason SuspiciousButExpected -WhatIf Should -Invoke Invoke-RestMethod -Times 0 -ParameterFilter { $Method -eq 'Put' } } } } |