tests/Set-MsecDefenderAlert.Tests.ps1

#Requires -Module Pester
#
# Tests for Set-MsecDefenderAlert - the first command in msec that changes anything outside its
# own app registration. The properties worth pinning are the ones that make a write safe rather
# than the ones that make it work:
#
# It refuses the app session. Connect-Msec grants only *.Read.All, so a write attempted on it
# can only 403. Saying so up front is the difference between a fixable message and a mystery.
#
# The breadth guard fires BEFORE anything is written. A guard that checks per item has already
# changed 25 alerts by the time it refuses the 26th, which is the whole failure it exists to
# prevent - so the test asserts zero PATCHes, not a smaller number.
#
# It reports what a re-read returned, never what was requested. When Defender accepts a PATCH
# and does not hold part of it, Changed must be false; when the re-read itself fails, the
# After columns must be $null rather than optimistically echoing the request.

BeforeAll {
    Import-Module (Join-Path $PSScriptRoot '..' 'msec.psm1') -Force -ErrorAction Stop
}
AfterAll { Remove-Module msec -Force -ErrorAction SilentlyContinue }

Describe 'Set-MsecDefenderAlert' {

    Context 'session requirements' {

        It 'refuses when only the read-only app session exists' {
            InModuleScope msec {
                $script:MsecSession = @{ TenantId = 't'; ClientId = 'c'; Tokens = @{} }
                $script:MsecAdminSession = $null

                { Set-MsecDefenderAlert -Id 'a1' -Status resolved -Confirm:$false } |
                    Should -Throw '*Connect-MsecAdmin*'
            }
        }

        It 'names the missing scope rather than letting the write 403' {
            InModuleScope msec {
                $script:MsecAdminSession = [PSCustomObject]@{
                    Account = 'me@contoso.com'; TenantId = 't'
                    GrantedScope = @('SecurityIncident.ReadWrite.All')
                }
                Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' } }
                Mock Start-Sleep -MockWith { }

                { Set-MsecDefenderAlert -Id 'a1' -Status resolved -Confirm:$false } |
                    Should -Throw '*SecurityAlert.ReadWrite.All*'
            }
        }

        It 'clears the recorded session when the Graph connection is gone' {
            InModuleScope msec {
                $script:MsecAdminSession = [PSCustomObject]@{
                    Account = 'me@contoso.com'; TenantId = 't'
                    GrantedScope = @('SecurityAlert.ReadWrite.All')
                }
                Mock Get-MgContext -MockWith { $null }

                { Set-MsecDefenderAlert -Id 'a1' -Status resolved -Confirm:$false } |
                    Should -Throw '*Connect-MsecAdmin again*'

                $script:MsecAdminSession | Should -BeNullOrEmpty
            }
        }
    }

    Context 'guards' {

        It 'refuses an empty change instead of sending a no-op PATCH' {
            InModuleScope msec {
                $script:MsecAdminSession = [PSCustomObject]@{
                    Account = 'me@contoso.com'; TenantId = 't'
                    GrantedScope = @('SecurityAlert.ReadWrite.All')
                }
                Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' } }
                Mock Start-Sleep -MockWith { }

                { Set-MsecDefenderAlert -Id 'a1' -Confirm:$false } |
                    Should -Throw '*at least one of*'
            }
        }

        It 'writes every alert piped in - there is no cap' {
            InModuleScope msec {
                $script:MsecAdminSession = [PSCustomObject]@{
                    Account = 'me@contoso.com'; TenantId = 't'
                    GrantedScope = @('SecurityAlert.ReadWrite.All')
                }
                Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' } }
                Mock Start-Sleep -MockWith { }
                Mock Invoke-MsecAdminGraphRequest -MockWith { @{ id = 'x'; status = 'resolved' } }

                $rows = @(1..40 | ForEach-Object { "alert$_" } |
                    Set-MsecDefenderAlert -Status resolved -Confirm:$false)

                $rows.Count | Should -Be 40
                Should -Invoke Invoke-MsecAdminGraphRequest -Times 40 -Scope It `
                    -ParameterFilter { $Method -eq 'PATCH' }
            }
        }

        It 'declares no MaxCount parameter at all' {
            # Removed deliberately. -WhatIf and the High ConfirmImpact prompt are what remain
            # between a broad filter and a bulk write; this pins that no cap crept back in.
            (Get-Command Set-MsecDefenderAlert).Parameters.Keys    | Should -Not -Contain 'MaxCount'
            (Get-Command Set-MsecDefenderIncident).Parameters.Keys | Should -Not -Contain 'MaxCount'
        }

        It 'sends no PATCH under -WhatIf' {
            InModuleScope msec {
                $script:MsecAdminSession = [PSCustomObject]@{
                    Account = 'me@contoso.com'; TenantId = 't'
                    GrantedScope = @('SecurityAlert.ReadWrite.All')
                }
                Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' } }
                Mock Start-Sleep -MockWith { }
                Mock Invoke-MsecAdminGraphRequest -MockWith { @{ id = 'a1'; status = 'new' } }

                Set-MsecDefenderAlert -Id 'a1' -Status resolved -WhatIf

                Should -Invoke Invoke-MsecAdminGraphRequest -Times 0 -Scope It
            }
        }

        It 'de-duplicates ids so one alert is written once' {
            InModuleScope msec {
                $script:MsecAdminSession = [PSCustomObject]@{
                    Account = 'me@contoso.com'; TenantId = 't'
                    GrantedScope = @('SecurityAlert.ReadWrite.All')
                }
                Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' } }
                Mock Start-Sleep -MockWith { }
                Mock Invoke-MsecAdminGraphRequest -MockWith { @{ id = 'a1'; status = 'resolved' } }

                $null = 'a1', 'a1', 'a1' | Set-MsecDefenderAlert -Status resolved -Confirm:$false

                Should -Invoke Invoke-MsecAdminGraphRequest -Times 1 -Scope It `
                    -ParameterFilter { $Method -eq 'PATCH' }
            }
        }
    }

    Context 'reporting the observed state' {

        It 'reports Changed false when Defender did not keep the change' {
            $row = InModuleScope msec {
                $script:MsecAdminSession = [PSCustomObject]@{
                    Account = 'me@contoso.com'; TenantId = 't'
                    GrantedScope = @('SecurityAlert.ReadWrite.All')
                }
                Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' } }
                Mock Start-Sleep -MockWith { }
                # PATCH succeeds; the alert comes back still 'new'.
                Mock Invoke-MsecAdminGraphRequest -MockWith {
                    @{ id = 'a1'; title = 'Suspicious sign-in'; severity = 'medium'; status = 'new' }
                }

                'a1' | Set-MsecDefenderAlert -Status resolved -Confirm:$false -WarningAction SilentlyContinue
            }

            $row.StatusBefore | Should -Be 'new'
            $row.StatusAfter  | Should -Be 'new'
            $row.Changed      | Should -BeFalse
        }

        It 'reports Changed true when the re-read confirms the change' {
            $row = InModuleScope msec {
                $script:MsecAdminSession = [PSCustomObject]@{
                    Account = 'me@contoso.com'; TenantId = 't'
                    GrantedScope = @('SecurityAlert.ReadWrite.All')
                }
                Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' } }
                Mock Start-Sleep -MockWith { }
                $script:calls = 0
                Mock Invoke-MsecAdminGraphRequest -MockWith {
                    $script:calls++
                    if ($script:calls -eq 1) { @{ id = 'a1'; title = 'T'; severity = 'low'; status = 'new' } }
                    else { @{ id = 'a1'; title = 'T'; severity = 'low'; status = 'resolved' } }
                }

                'a1' | Set-MsecDefenderAlert -Status resolved -Confirm:$false
            }

            $row.StatusBefore | Should -Be 'new'
            $row.StatusAfter  | Should -Be 'resolved'
            $row.Changed      | Should -BeTrue
        }

        It 'leaves the After columns null when the re-read fails, rather than echoing the request' {
            $row = InModuleScope msec {
                $script:MsecAdminSession = [PSCustomObject]@{
                    Account = 'me@contoso.com'; TenantId = 't'
                    GrantedScope = @('SecurityAlert.ReadWrite.All')
                }
                Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' } }
                Mock Start-Sleep -MockWith { }
                $script:calls = 0
                Mock Invoke-MsecAdminGraphRequest -MockWith {
                    $script:calls++
                    # 1 = read before, 2 = PATCH, 3 = re-read which fails.
                    if ($script:calls -ge 3) { throw 'Gateway timeout' }
                    @{ id = 'a1'; title = 'T'; severity = 'high'; status = 'new' }
                }

                'a1' | Set-MsecDefenderAlert -Status resolved -Confirm:$false -WarningAction SilentlyContinue
            }

            $row.StatusBefore | Should -Be 'new'
            $row.StatusAfter  | Should -BeNullOrEmpty
            $row.Changed      | Should -BeNullOrEmpty
        }

        It 'still emits a row when the PATCH fails, with Changed null rather than false' {
            # Changed to this contract when -Comment arrived: a write is no longer all-or-
            # nothing, so the comment may have landed while the Graph fields did not. A row
            # carrying Changed=$null and a warning beats silence that a pipeline swallows.
            # $null, not $false: the change was not observed to fail, it was never verified.
            $rows = InModuleScope msec {
                $script:MsecAdminSession = [PSCustomObject]@{
                    Account = 'me@contoso.com'; TenantId = 't'
                    GrantedScope = @('SecurityAlert.ReadWrite.All')
                }
                Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' } }
                Mock Start-Sleep -MockWith { }
                Mock Invoke-MsecAdminGraphRequest -MockWith {
                    if ($Method -eq 'PATCH') { throw 'Forbidden' }
                    @{ id = 'a1'; title = 'T'; severity = 'low'; status = 'new' }
                }

                @('a1' | Set-MsecDefenderAlert -Status resolved -Confirm:$false -WarningAction SilentlyContinue)
            }

            @($rows).Count       | Should -Be 1
            $rows.Changed        | Should -BeNullOrEmpty
            $rows.StatusBefore   | Should -Be 'new'
            $rows.StatusAfter    | Should -BeNullOrEmpty
        }
    }

    Context 'eventual consistency' {

        It 'does not cry wolf when the first read is stale but the change settled' {
            # The regression this guards. Observed live: an alert PATCHed at 16:37:00 still read
            # as unchanged immediately afterwards and was correct when read again. Reporting a
            # failure that did not happen trains people to ignore the warning, which destroys
            # the value of verifying at all.
            $result = InModuleScope msec {
                $script:MsecAdminSession = [PSCustomObject]@{
                    Account = 'me@contoso.com'; TenantId = 't'
                    GrantedScope = @('SecurityAlert.ReadWrite.All')
                }
                Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me'; TenantId = 't' } }
                Mock Start-Sleep -MockWith { }

                # 1 = read before, 2 = PATCH, 3 = STALE verify, 4 = settled verify.
                $script:n = 0
                Mock Invoke-MsecAdminGraphRequest -MockWith {
                    $script:n++
                    if ($script:n -le 3) {
                        @{ id = 'a1'; title = 'T'; severity = 'medium'; status = 'new' }
                    }
                    else {
                        @{ id = 'a1'; title = 'T'; severity = 'medium'; status = 'resolved' }
                    }
                }

                $row = 'a1' | Set-MsecDefenderAlert -Status resolved -Confirm:$false `
                    -WarningVariable w -WarningAction SilentlyContinue
                [PSCustomObject]@{ Row = $row; Warnings = @($w) }
            }

            $result.Row.StatusAfter | Should -Be 'resolved'
            $result.Row.Changed     | Should -BeTrue
            $result.Warnings.Count  | Should -Be 0
        }

        It 'still reports a mismatch when the value never settles' {
            $result = InModuleScope msec {
                $script:MsecAdminSession = [PSCustomObject]@{
                    Account = 'me@contoso.com'; TenantId = 't'
                    GrantedScope = @('SecurityAlert.ReadWrite.All')
                }
                Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me'; TenantId = 't' } }
                Mock Start-Sleep -MockWith { }
                Mock Invoke-MsecAdminGraphRequest -MockWith {
                    @{ id = 'a1'; title = 'T'; severity = 'medium'; status = 'new' }
                }

                $row = 'a1' | Set-MsecDefenderAlert -Status resolved -Confirm:$false `
                    -WarningVariable w -WarningAction SilentlyContinue
                [PSCustomObject]@{ Row = $row; Warnings = @($w) }
            }

            $result.Row.Changed | Should -BeFalse
            ($result.Warnings -join ' ') | Should -Match 'still does not show'
        }
    }

    Context 'the enum values Graph actually accepts' {

        It 'takes the wire status value new, not the CSDL member newAlert' {
            $cmd = Get-Command Set-MsecDefenderAlert
            $values = $cmd.Parameters['Status'].Attributes.Where({ $_ -is [ValidateSet] }).ValidValues
            $values | Should -Contain 'new'
            $values | Should -Not -Contain 'newAlert'
        }

        It 'uses the real determination names, not the guessable ones' {
            $cmd = Get-Command Set-MsecDefenderAlert
            $values = $cmd.Parameters['Determination'].Attributes.Where({ $_ -is [ValidateSet] }).ValidValues
            $values | Should -Contain 'notMalicious'
            $values | Should -Contain 'notEnoughDataToValidate'
            $values | Should -Not -Contain 'clean'
            $values | Should -Not -Contain 'insufficientData'
        }

        It 'declares High confirm impact so a bare call prompts' {
            $meta = [System.Management.Automation.CommandMetadata](Get-Command Set-MsecDefenderAlert)
            $meta.SupportsShouldProcess | Should -BeTrue
            $meta.ConfirmImpact | Should -Be 'High'
        }
    }
}

Describe 'One identity per command' {

    It 'has no Comment parameter, and no second authentication path' {
        # A -Comment switch existed briefly, routed to the Defender for Endpoint API on a
        # separate Az-context token. That put two different USER identities inside one command -
        # the alert could be resolved by one person and commented by another - in exchange for
        # covering 29 of 569 alerts on the measured tenant. Removed; the note goes on the
        # incident instead.
        (Get-Command Set-MsecDefenderAlert).Parameters.Keys | Should -Not -Contain 'Comment'
        (Get-Command Set-MsecDefenderIncident).Parameters.Keys | Should -Contain 'ResolvingComment'
    }

    It 'reaches only the delegated Graph session' {
        # The whole point: one principal, one session. A second transport appearing here is a
        # regression whatever it is authenticated with.
        $body = (Get-Command Set-MsecDefenderAlert).Definition
        $body | Should -Match 'Invoke-MsecAdminGraphRequest'
        $body | Should -Not -Match 'Invoke-MsecAdminDefenderRequest'
        $body | Should -Not -Match 'Get-AzAccessToken'
    }
}