tests/New-MsecDefenderDetectionRule.Tests.ps1

#Requires -Module Pester
#
# Tests for New-MsecDefenderDetectionRule.
#
# The command's reason to exist is that it RUNS the query before creating the rule, so most of
# these cover the refusals:
# - a query that does not run must not become a rule; Defender would accept it and then
# autoDisable it later, leaving a dead rule that looks live.
# - a query missing a column the entity mapping names must be refused, naming the column,
# because Defender's own rejection does not say which.
# - a query matching NOTHING must still be allowed - that is the normal state of a good
# detection, and refusing it would block exactly the rules worth having.
# - a query matching a lot must warn before creating an alert cannon.
# - 403 must blame the user's scope, not the app's.

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

Describe 'New-MsecDefenderDetectionRule' {
    BeforeEach {
        InModuleScope Msec {
            Mock Assert-MsecSession -MockWith { }
            Mock Assert-MsecAdminSession -MockWith { }
            Mock Invoke-MsecAdminGraphRequest -MockWith {
                $b = $Body
                [pscustomobject]@{
                    id = $b.id; displayName = $b.displayName; description = $b.description
                    status = $b.status
                    queryCondition = [pscustomobject]@{ queryText = $b.queryCondition.queryText }
                    schedule = [pscustomobject]@{ frequency = $b.schedule.frequency }
                    detectionAction = [pscustomobject]@{ alertTemplate = [pscustomobject]@{
                        title = $b.detectionAction.alertTemplate.title
                        severity = $b.detectionAction.alertTemplate.severity } }
                    createdBy = 'me@contoso.com'; createdDateTime = '2026-10-08T10:00:00Z'
                }
            }
        }
    }

    It 'refuses to create a rule whose query does not run' {
        $err = InModuleScope Msec {
            Mock Search-MsecDefenderHunting -MockWith { throw "Failed to resolve table named 'NoSuchTable'" }
            try {
                New-MsecDefenderDetectionRule -DisplayName 'Broken' -Query 'NoSuchTable | take 1' `
                    -Description 'x' -Confirm:$false
                $null
            } catch { "$($_.Exception.Message)" }
        }

        $err | Should -Match 'does not run'
        $err | Should -Match 'autoDisabled'
        InModuleScope Msec { Should -Invoke Invoke-MsecAdminGraphRequest -Times 0 -Exactly }
    }

    It 'refuses when the query omits a column the entity mapping names, and says which' {
        $err = InModuleScope Msec {
            Mock Search-MsecDefenderHunting -MockWith {
                @([pscustomobject]@{ Timestamp = (Get-Date); DeviceName = 'vie0001' })   # no DeviceId
            }
            try {
                New-MsecDefenderDetectionRule -DisplayName 'No device id' -Query 'DeviceEvents' `
                    -Description 'x' -Confirm:$false
                $null
            } catch { "$($_.Exception.Message)" }
        }

        $err | Should -Match 'DeviceId'
        # Defender's own error does not name the column, which is the whole reason for this check.
        $err | Should -Match 'does not say which'
        InModuleScope Msec { Should -Invoke Invoke-MsecAdminGraphRequest -Times 0 -Exactly }
    }

    It 'creates a rule whose query matches nothing - the normal state of a good detection' {
        $row = InModuleScope Msec {
            Mock Search-MsecDefenderHunting -MockWith { @() }
            New-MsecDefenderDetectionRule -DisplayName 'Quiet rule' -Query 'DeviceEvents | where 1 == 0' `
                -Description 'Should be silent' -Confirm:$false
        }

        $row.Id | Should -Be 'quiet-rule'      # slug derived from the name
        $row.Status | Should -Be 'enabled'
        $row.ValidationRows | Should -Be 0
        InModuleScope Msec { Should -Invoke Invoke-MsecAdminGraphRequest -Times 1 -Exactly }
    }

    It 'warns before creating a rule that would match hundreds of rows' {
        $out = InModuleScope Msec {
            Mock Search-MsecDefenderHunting -MockWith {
                1..200 | ForEach-Object { [pscustomobject]@{ Timestamp = (Get-Date); DeviceId = "d$_"; DeviceName = "n$_" } }
            }
            $w = @()
            $r = New-MsecDefenderDetectionRule -DisplayName 'Noisy' -Query 'DeviceEvents' `
                    -Description 'x' -Confirm:$false -WarningVariable w -WarningAction SilentlyContinue
            [pscustomobject]@{ Row = $r; Warnings = "$($w -join ' ')" }
        }

        $out.Warnings | Should -Match '200 row'
        $out.Warnings | Should -Match 'somebody switches off'
        # Warned, but still created - the caller may know it is a reporting rule.
        $out.Row.ValidationRows | Should -Be 200
    }

    It 'maps frequency, severity and MITRE into the request body' {
        $body = InModuleScope Msec {
            $script:sent = $null
            Mock Search-MsecDefenderHunting -MockWith { @() }
            Mock Invoke-MsecAdminGraphRequest -MockWith {
                $script:sent = $Body
                [pscustomobject]@{ id=$Body.id; displayName=$Body.displayName; status=$Body.status
                                   queryCondition=[pscustomobject]@{}; schedule=[pscustomobject]@{}
                                   detectionAction=[pscustomobject]@{ alertTemplate=[pscustomobject]@{} } }
            }
            New-MsecDefenderDetectionRule -DisplayName 'Safe Mode blocked' -Query 'DeviceEvents' `
                -Description 'x' -Severity medium -Frequency 3h `
                -Tactic DefenseEvasion -Technique 'T1562.009' -Confirm:$false | Out-Null
            $script:sent
        }

        $body.schedule.frequency | Should -Be 'PT3H'
        $body.detectionAction.alertTemplate.severity | Should -Be 'medium'
        $body.detectionAction.alertTemplate.tactics[0].tactic | Should -Be 'DefenseEvasion'
        $body.detectionAction.alertTemplate.tactics[0].techniques[0].technique | Should -Be 'T1562.009'
        $body.detectionAction.alertTemplate.entityMappings.hosts[0].deviceIdColumn | Should -Be 'DeviceId'
        $body.status | Should -Be 'enabled'
    }

    It 'rejects a technique without a tactic, which Defender refuses server-side' {
        $err = InModuleScope Msec {
            Mock Search-MsecDefenderHunting -MockWith { @() }
            try {
                New-MsecDefenderDetectionRule -DisplayName 'x' -Query 'DeviceEvents' -Description 'x' `
                    -Technique 'T1562.009' -Confirm:$false
                $null
            } catch { "$($_.Exception.Message)" }
        }
        $err | Should -Match 'technique without a tactic'
    }

    It 'creates nothing under -WhatIf' {
        InModuleScope Msec {
            Mock Search-MsecDefenderHunting -MockWith { @() }
            New-MsecDefenderDetectionRule -DisplayName 'Dry run' -Query 'DeviceEvents' -Description 'x' -WhatIf | Out-Null
            Should -Invoke Invoke-MsecAdminGraphRequest -Times 0 -Exactly
        }
    }

    It 'blames the signed-in user for a 403, and names the only scope that works' {
        $err = InModuleScope Msec {
            Mock Search-MsecDefenderHunting -MockWith { @() }
            Mock Invoke-MsecAdminGraphRequest -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' }
            try { New-MsecDefenderDetectionRule -DisplayName 'x' -Query 'DeviceEvents' -Description 'x' -Confirm:$false; $null }
            catch { "$($_.Exception.Message)" }
        }

        $err | Should -Match 'CustomDetection\.ReadWrite\.All'
        $err | Should -Match 'no read-only or lesser scope'
        $err | Should -Match 'YOUR authorization'
    }
}