tests/Get-MsecDefenderIndicator.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecDefenderIndicator. It LISTS the Defender indicator API on a DELEGATED
# token from the Az context, because that API has no read-only scope and the msec app is
# deliberately read-only.
#
# The traps:
#
# THIS API HAS NO Ti.Read. Listing needs Ti.ReadWrite, a write scope, so a 403 here is an
# authorization failure on the USER and the error must say so - sending someone to check
# the app registration wastes the afternoon.
#
# NO EXPIRY IS THE NORMAL CASE. Measured on one tenant, 63 of 63 indicators had no
# expirationTime. $null means permanent; a far-future date would be a different fact.
#
# EMPTY SCOPE MEANS TENANT-WIDE. rbacGroupNames comes back empty when an indicator applies
# everywhere, which reads like missing data and is the opposite.

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

Describe 'Get-MsecDefenderIndicator' {
    BeforeEach {
        InModuleScope Msec {
            Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.com' } } }
            Mock Get-AzAccessToken -MockWith { [pscustomobject]@{ Token = 'delegated-token' } }
            Mock Get-MsecEnvironment -MockWith {
                @{ DefenderResource = 'https://api.securitycenter.microsoft.com'; EnvironmentName = 'AzureCloud' }
            }
        }
    }

    It 'names the USER scope on a 403, not the app registration' {
        # THE BUG THIS PREVENTS. The msec app is read-only by design and will never hold
        # Ti.ReadWrite, so "check the app permissions" is always the wrong advice here.
        $msg = InModuleScope Msec {
            Mock Invoke-RestMethod -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' }
            try { Get-MsecDefenderIndicator | Out-Null } catch { $_.Exception.Message }
        }

        $msg | Should -BeLike '*Ti.ReadWrite*'
        $msg | Should -BeLike '*me@contoso.com*'
        $msg | Should -BeLike '*not on the msec app*'
    }

    It 'reports no expiry as $null rather than inventing a date' {
        $r = InModuleScope Msec {
            Mock Invoke-RestMethod -MockWith {
                @{ value = @(
                    [pscustomobject]@{ id='1'; indicatorValue='evil.example'; indicatorType='DomainName'
                                       action='Block'; expirationTime=$null; rbacGroupNames=@() },
                    [pscustomobject]@{ id='2'; indicatorValue='other.example'; indicatorType='DomainName'
                                       action='Block'; expirationTime='2027-01-01T00:00:00Z'; rbacGroupNames=@() }
                ) }
            }
            Get-MsecDefenderIndicator
        }

        ($r | Where-Object Id -eq '1').ExpiresUtc | Should -BeNullOrEmpty
        ($r | Where-Object Id -eq '2').ExpiresUtc | Should -BeOfType [datetime]
    }

    It 'calls an empty scope tenant-wide, because blank reads as missing data' {
        $r = InModuleScope Msec {
            Mock Invoke-RestMethod -MockWith {
                @{ value = @(
                    [pscustomobject]@{ id='1'; indicatorValue='a'; indicatorType='Url'; action='Audit'
                                       rbacGroupNames=@() },
                    [pscustomobject]@{ id='2'; indicatorValue='b'; indicatorType='Url'; action='Audit'
                                       rbacGroupNames=@('Servers','Laptops') }
                ) }
            }
            Get-MsecDefenderIndicator
        }

        ($r | Where-Object Id -eq '1').Scope | Should -Be 'Tenant-wide'
        ($r | Where-Object Id -eq '2').Scope | Should -Be 'Servers; Laptops'
    }

    It 'follows @odata.nextLink, so a long inventory is not silently truncated' {
        # A security inventory that stops at one page is worse than a slow one: the missing
        # entries look like indicators that were never created.
        $r = InModuleScope Msec {
            $script:Calls = 0
            Mock Invoke-RestMethod -MockWith {
                $script:Calls++
                if ($script:Calls -eq 1) {
                    @{ value = @([pscustomobject]@{ id='1'; indicatorValue='a'; indicatorType='Url'; action='Block'; rbacGroupNames=@() })
                       '@odata.nextLink' = 'https://api.securitycenter.microsoft.com/api/indicators?page=2' }
                }
                else {
                    @{ value = @([pscustomobject]@{ id='2'; indicatorValue='b'; indicatorType='Url'; action='Block'; rbacGroupNames=@() }) }
                }
            }
            Get-MsecDefenderIndicator
        }

        @($r).Count | Should -Be 2
        @($r).Id    | Should -Contain '2'
    }

    It 'warns rather than throwing for a type this tenant does not have' {
        # Microsoft adds indicator types without warning, so an unmatched filter is an empty
        # result and a note, not an invalid argument.
        $w = InModuleScope Msec {
            Mock Invoke-RestMethod -MockWith {
                @{ value = @([pscustomobject]@{ id='1'; indicatorValue='a'; indicatorType='Url'; action='Block'; rbacGroupNames=@() }) }
            }
            $r = Get-MsecDefenderIndicator -Type 'FileSha256' -WarningVariable wv -WarningAction SilentlyContinue
            [pscustomobject]@{ Count = @($r).Count; Warning = ($wv -join ' ') }
        }

        $w.Count   | Should -Be 0
        $w.Warning | Should -BeLike '*FileSha256*'
        $w.Warning | Should -BeLike '*Url*'
    }

    It 'refuses a sovereign cloud instead of returning an empty list' {
        # Defender for Endpoint is commercial-only. An empty result there would read as
        # "no indicators configured", which is a different and wrong answer.
        $msg = InModuleScope Msec {
            Mock Get-MsecEnvironment -MockWith { @{ DefenderResource = $null; EnvironmentName = 'AzureChinaCloud' } }
            try { Get-MsecDefenderIndicator | Out-Null } catch { $_.Exception.Message }
        }

        $msg | Should -BeLike '*AzureChinaCloud*'
        $msg | Should -BeLike '*commercial-only*'
    }

    It 'names Connect-MsecAzure when there is no Az context' {
        $msg = InModuleScope Msec {
            Mock Get-AzContext -MockWith { $null }
            try { Get-MsecDefenderIndicator | Out-Null } catch { $_.Exception.Message }
        }

        $msg | Should -BeLike '*Connect-MsecAzure*'
        $msg | Should -BeLike '*not as the msec app*'
    }
}