tests/Security.Module.Tests.ps1

#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
#Requires -Modules ImportExcel

<#
.SYNOPSIS
    Pester tests for all Security inventory modules.
 
.DESCRIPTION
    Tests both Processing and Reporting phases for Security modules.
    Defender modules (DefenderAlerts, DefenderAssessments, DefenderPricing,
    DefenderSecureScore) call live Az Security cmdlets in Processing — those
    are verified for graceful failure and empty-data Reporting behavior.
    Key Vault (Vault.ps1) uses $Resources and is fully tested with mock data.
 
.NOTES
    Author: AzureScout Contributors
    Version: 1.0.0
    Created: 2026-02-24
    Phase: 9.2 — Defender for Cloud Testing
#>


# ===================================================================
# DISCOVERY-TIME
# ===================================================================
$SecurityPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'Modules' 'Public' 'InventoryModules' 'Security'

$SecurityModuleFiles = @(
    @{ Name = 'Vault';                File = 'Vault.ps1' }
    @{ Name = 'DefenderAlerts';       File = 'DefenderAlerts.ps1' }
    @{ Name = 'DefenderAssessments';  File = 'DefenderAssessments.ps1' }
    @{ Name = 'DefenderPricing';      File = 'DefenderPricing.ps1' }
    @{ Name = 'DefenderSecureScore';  File = 'DefenderSecureScore.ps1' }
)

# ===================================================================
# EXECUTION-TIME SETUP
# ===================================================================
BeforeAll {
    $script:ModuleRoot   = Split-Path -Parent $PSScriptRoot

    # Collectors are executed here as bare scriptblocks, NOT through the imported module, so any
    # private helper they call has to be dot-sourced in explicitly. Get-AZSCSafeProperty is the
    # StrictMode-safe dotted-path read that converted collectors use instead of a raw `$data.a.b`
    # chain (which throws when an intermediate segment is genuinely ABSENT rather than $null), and
    # Get-AZSCCollectedValue is its member-ENUMERATION counterpart for a read over a collection
    # that may be empty. Both are needed here from AB#5671 onwards.
    . (Join-Path $script:ModuleRoot 'Modules' 'Private' 'Main' 'Get-AZSCSafeProperty.ps1')
    . (Join-Path $script:ModuleRoot 'Modules' 'Private' 'Main' 'Get-AZTICollectedValue.ps1')
    # Get-AZSCIdSegment (AB#5671) guards the FIXED .split('/')[8] index ~30 collectors use to pull
    # a name out of a related resource id -- an out-of-range index THROWS under StrictMode, where
    # without it the same expression quietly returned $null.
    . (Join-Path $script:ModuleRoot 'Modules' 'Private' 'Main' 'Get-AZSCIdSegment.ps1')
    $script:SecurityPath = Join-Path $script:ModuleRoot 'Modules' 'Public' 'InventoryModules' 'Security'
    $script:TempDir      = Join-Path $env:TEMP 'AZSC_SecurityTests'

    if (Test-Path $script:TempDir) { Remove-Item $script:TempDir -Recurse -Force }
    New-Item -ItemType Directory -Path $script:TempDir -Force | Out-Null

    function New-MockSecResource {
        param([string]$Id, [string]$Name, [string]$Type, [string]$Kind = '',
              [string]$Location = 'eastus', [string]$RG = 'rg-sec',
              [string]$SubscriptionId = 'sub-00000001', [object]$Props)
        [PSCustomObject]@{
            id             = $Id
            NAME           = $Name
            TYPE           = $Type
            KIND           = $Kind
            LOCATION       = $Location
            RESOURCEGROUP  = $RG
            subscriptionId = $SubscriptionId
            tags           = [PSCustomObject]@{}
            PROPERTIES     = $Props
        }
    }

    $script:MockResources = @()

    # Key Vault
    $script:MockResources += New-MockSecResource -Id '/sec/kv1' -Name 'kv-prod' `
        -Type 'microsoft.keyvault/vaults' -Props ([PSCustomObject]@{
        sku             = [PSCustomObject]@{ name = 'premium'; family = 'A' }
        tenantId        = 'tenant-001'
        enabledForDeployment         = $false
        enabledForTemplateDeployment = $true
        enabledForDiskEncryption     = $true
        enableSoftDelete             = $true
        softDeleteRetentionInDays    = 90
        enablePurgeProtection        = $true
        enableRbacAuthorization      = $true
        publicNetworkAccess          = 'Disabled'
        networkAcls = [PSCustomObject]@{ bypass = 'AzureServices'; defaultAction = 'Deny'; ipRules = @(); virtualNetworkRules = @() }
        provisioningState = 'Succeeded'
        vaultUri = 'https://kv-prod.vault.azure.net/'
    })
}

AfterAll {
    if (Test-Path $script:TempDir) { Remove-Item $script:TempDir -Recurse -Force }
}

# ===================================================================
# TESTS
# ===================================================================
Describe 'Security Module Files Exist' {
    It 'Security module folder exists' {
        $script:SecurityPath | Should -Exist
    }

    It '<Name> module file exists' -ForEach $SecurityModuleFiles {
        Join-Path $script:SecurityPath $File | Should -Exist
    }
}

Describe 'Key Vault (Vault.ps1) — Processing' {
    It 'Processing returns results for Key Vault resources' {
        $modFile = Join-Path $script:SecurityPath 'Vault.ps1'
        $content = Get-Content -Path $modFile -Raw
        $sb      = [ScriptBlock]::Create($content)
        $result  = Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, $script:MockResources, $null, 'Processing', $null, $null, 'Light20', $null
        $result | Should -Not -BeNullOrEmpty
    }

    It 'Processing returns no output for empty resource list without throwing' {
        $modFile = Join-Path $script:SecurityPath 'Vault.ps1'
        $content = Get-Content -Path $modFile -Raw
        $sb      = [ScriptBlock]::Create($content)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, @(), $null, 'Processing', $null, $null, 'Light20', $null } | Should -Not -Throw
    }

    It 'Processing output contains expected Key Vault fields' {
        $modFile = Join-Path $script:SecurityPath 'Vault.ps1'
        $content = Get-Content -Path $modFile -Raw
        $sb      = [ScriptBlock]::Create($content)
        $result  = Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, $script:MockResources, $null, 'Processing', $null, $null, 'Light20', $null
        $row = $result | Select-Object -First 1
        $row.Keys | Should -Contain 'Name'
        $row.Keys | Should -Contain 'Subscription'
        $row.Keys | Should -Contain 'Resource Group'
    }
}

Describe 'Key Vault (Vault.ps1) — Reporting' {
    BeforeAll {
        $modFile  = Join-Path $script:SecurityPath 'Vault.ps1'
        $content  = Get-Content -Path $modFile -Raw
        $sb       = [ScriptBlock]::Create($content)
        $script:KvProcessed = Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, $script:MockResources, $null, 'Processing', $null, $null, 'Light20', $null
        $script:KvXlsx = Join-Path $script:TempDir 'Vault_test.xlsx'
    }

    It 'Reporting phase does not throw' {
        $modFile = Join-Path $script:SecurityPath 'Vault.ps1'
        $content = Get-Content -Path $modFile -Raw
        $sb      = [ScriptBlock]::Create($content)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, $null, $null, 'Reporting', $script:KvXlsx, $script:KvProcessed, 'Light20', $null } | Should -Not -Throw
    }

    It 'Excel file is created' {
        $script:KvXlsx | Should -Exist
    }
}

Describe 'Defender Modules — Graceful Behavior Without Live Azure Connection' {

    It 'DefenderAlerts Processing does not throw when Get-AzSecurityAlert is unavailable' {
        $modFile = Join-Path $script:SecurityPath 'DefenderAlerts.ps1'
        $content = Get-Content -Path $modFile -Raw
        $sb      = [ScriptBlock]::Create($content)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, @(), $null, 'Processing', $null, $null, 'Light20', $null } | Should -Not -Throw
    }

    It 'DefenderAssessments Processing does not throw when Get-AzSecurityAssessment is unavailable' {
        $modFile = Join-Path $script:SecurityPath 'DefenderAssessments.ps1'
        $content = Get-Content -Path $modFile -Raw
        $sb      = [ScriptBlock]::Create($content)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, @(), $null, 'Processing', $null, $null, 'Light20', $null } | Should -Not -Throw
    }

    It 'DefenderPricing Processing does not throw when Get-AzSecurityPricing is unavailable' {
        $modFile = Join-Path $script:SecurityPath 'DefenderPricing.ps1'
        $content = Get-Content -Path $modFile -Raw
        $sb      = [ScriptBlock]::Create($content)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, @(), $null, 'Processing', $null, $null, 'Light20', $null } | Should -Not -Throw
    }

    It 'DefenderSecureScore Processing does not throw when Get-AzSecuritySecureScore is unavailable' {
        $modFile = Join-Path $script:SecurityPath 'DefenderSecureScore.ps1'
        $content = Get-Content -Path $modFile -Raw
        $sb      = [ScriptBlock]::Create($content)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, @(), $null, 'Processing', $null, $null, 'Light20', $null } | Should -Not -Throw
    }

    It 'DefenderAlerts Reporting does not throw with empty data' {
        $modFile  = Join-Path $script:SecurityPath 'DefenderAlerts.ps1'
        $content  = Get-Content -Path $modFile -Raw
        $xlsxFile = Join-Path $script:TempDir 'DefenderAlerts_empty.xlsx'
        $sb       = [ScriptBlock]::Create($content)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, $null, $null, 'Reporting', $xlsxFile, $null, 'Light20', $null } | Should -Not -Throw
    }

    It 'DefenderAssessments Reporting does not throw with empty data' {
        $modFile  = Join-Path $script:SecurityPath 'DefenderAssessments.ps1'
        $content  = Get-Content -Path $modFile -Raw
        $xlsxFile = Join-Path $script:TempDir 'DefenderAssessments_empty.xlsx'
        $sb       = [ScriptBlock]::Create($content)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, $null, $null, 'Reporting', $xlsxFile, $null, 'Light20', $null } | Should -Not -Throw
    }

    It 'DefenderPricing Reporting does not throw with empty data' {
        $modFile  = Join-Path $script:SecurityPath 'DefenderPricing.ps1'
        $content  = Get-Content -Path $modFile -Raw
        $xlsxFile = Join-Path $script:TempDir 'DefenderPricing_empty.xlsx'
        $sb       = [ScriptBlock]::Create($content)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, $null, $null, 'Reporting', $xlsxFile, $null, 'Light20', $null } | Should -Not -Throw
    }

    It 'DefenderSecureScore Reporting does not throw with empty data' {
        $modFile  = Join-Path $script:SecurityPath 'DefenderSecureScore.ps1'
        $content  = Get-Content -Path $modFile -Raw
        $xlsxFile = Join-Path $script:TempDir 'DefenderSecureScore_empty.xlsx'
        $sb       = [ScriptBlock]::Create($content)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, $null, $null, 'Reporting', $xlsxFile, $null, 'Light20', $null } | Should -Not -Throw
    }
}

Describe 'Defender modules contain correct Azure resource type filters' {
    It 'DefenderAlerts.ps1 contains Get-AzSecurityAlert call' {
        $modFile  = Join-Path $script:SecurityPath 'DefenderAlerts.ps1'
        $content  = Get-Content -Path $modFile -Raw
        $content | Should -Match 'Get-AzSecurityAlert'
    }

    It 'DefenderAssessments.ps1 contains Get-AzSecurityAssessment call' {
        $modFile = Join-Path $script:SecurityPath 'DefenderAssessments.ps1'
        $content = Get-Content -Path $modFile -Raw
        $content | Should -Match 'Get-AzSecurityAssessment'
    }

    It 'DefenderPricing.ps1 contains Get-AzSecurityPricing call' {
        $modFile = Join-Path $script:SecurityPath 'DefenderPricing.ps1'
        $content = Get-Content -Path $modFile -Raw
        $content | Should -Match 'Get-AzSecurityPricing'
    }

    It 'DefenderSecureScore.ps1 contains Get-AzSecuritySecureScore call' {
        $modFile = Join-Path $script:SecurityPath 'DefenderSecureScore.ps1'
        $content = Get-Content -Path $modFile -Raw
        $content | Should -Match 'Get-AzSecuritySecureScore'
    }
}