tests/Management.Module.Tests.ps1

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

<#
.SYNOPSIS
    Pester tests for all Management & Governance inventory modules.
 
.DESCRIPTION
    Tests both Processing and Reporting phases for resource-based Management modules
    using synthetic mock data. Modules that call live Az cmdlets (ManagementGroups,
    CustomRoleDefinitions, PolicyDefinitions, PolicySetDefinitions, PolicyComplianceStates)
    are tested by mocking those cmdlets.
    No live Azure authentication is required.
 
.NOTES
    Author: AzureScout Contributors
    Version: 1.0.0
    Created: 2026-02-24
    Phase: 9.1, 11.1, 11.2, 19.6 — Management / Policy / Subscriptions Testing
#>


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

# Modules that filter $Resources
$MgmtResourceModules = @(
    @{ Name = 'MaintenanceConfigurations'; File = 'MaintenanceConfigurations.ps1'; Type = 'microsoft.maintenance/maintenanceconfigurations'; Worksheet = 'Maintenance Configurations' }
    @{ Name = 'RecoveryVault';             File = 'RecoveryVault.ps1';             Type = 'microsoft.recoveryservices/vaults';               Worksheet = 'Recovery Vaults' }
    @{ Name = 'LighthouseDelegations';     File = 'LighthouseDelegations.ps1';     Type = 'Microsoft.ManagedServices/registrationDefinitions'; Worksheet = 'Lighthouse Delegations' }
    @{ Name = 'AdvisorScore';              File = 'AdvisorScore.ps1';              Type = 'Microsoft.Advisor/advisorScore';                  Worksheet = 'Advisor Score' }
    @{ Name = 'SupportTickets';            File = 'SupportTickets.ps1';            Type = 'Microsoft.Support/supportTickets';                Worksheet = 'Support Tickets' }
    @{ Name = 'ReservationRecom';          File = 'ReservationRecom.ps1';          Type = 'Microsoft.Consumption/reservationRecommendations'; Worksheet = 'Reservation Recommendations' }
    @{ Name = 'AutomationAccounts';        File = 'AutomationAccounts.ps1';        Type = 'microsoft.automation/automationaccounts';          Worksheet = 'Runbooks' }
    @{ Name = 'Backup';                    File = 'Backup.ps1';                    Type = 'microsoft.recoveryservices/vaults/backuppolicies'; Worksheet = 'Backup' }
)

# ===================================================================
# EXECUTION-TIME SETUP
# ===================================================================
BeforeAll {
    $script:ModuleRoot     = Split-Path -Parent $PSScriptRoot
    $script:ManagementPath = Join-Path $script:ModuleRoot 'Modules' 'Public' 'InventoryModules' 'Management'
    $script:TempDir        = Join-Path $env:TEMP 'AZSC_ManagementTests'

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

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

    # Mock subscription array for modules that use $SUB
    $script:MockSubs = @(
        [PSCustomObject]@{ Id = 'sub-00000001'; Name = 'Test Subscription' }
    )

    $script:MockResources = @()

    # Maintenance Configurations
    $script:MockResources += New-MockMgmtResource -Id '/sub/mc1' -Name 'mc-patches' `
        -Type 'microsoft.maintenance/maintenanceconfigurations' -Props ([PSCustomObject]@{
        maintenanceScope = 'InGuestPatch'; maintenanceWindow = [PSCustomObject]@{
            startDateTime = '2026-03-01 02:00'; duration = '02:00'; recurEvery = 'Week Thursday'
            timeZone = 'Eastern Standard Time'; expirationDateTime = $null
        }
        installPatches = [PSCustomObject]@{
            rebootSetting = 'IfRequired'
            windowsParameters = [PSCustomObject]@{ classificationsToInclude = @('Critical','Security') }
        }
        namespace = 'guestOS'; visibility = 'Custom'
    })

    # Recovery Vault
    $script:MockResources += New-MockMgmtResource -Id '/sub/rv1' -Name 'rv-prod' `
        -Type 'microsoft.recoveryservices/vaults' -Props ([PSCustomObject]@{
        provisioningState = 'Succeeded'
        sku = [PSCustomObject]@{ name = 'RS0'; tier = 'Standard' }
        publicNetworkAccess = 'Enabled'
        redundancySettings = [PSCustomObject]@{ standardTierStorageRedundancy = 'GeoRedundant' }
    })

    # Lighthouse Delegations
    $script:MockResources += New-MockMgmtResource -Id '/sub/ld1' -Name 'ld-mssp' `
        -Type 'Microsoft.ManagedServices/registrationDefinitions' -Props ([PSCustomObject]@{
        description = 'MSSP Managed Service'; managedByTenantId = 'tenant-mssp-001'
        managedByTenantName = 'MSSP Corp'
        authorizations = @(
            [PSCustomObject]@{ principalId = 'sp-001'; principalIdDisplayName = 'MSSP Engineers'; roleDefinitionId = 'b24988ac-6180-42a0-ab88-20f7382dd24c' }
        )
    })

    # Advisor Score — module filters for name in (Cost, Security, etc.) and reads timeSeries/scoreHistory
    $script:MockResources += New-MockMgmtResource `
        -Id '/subscriptions/sub-00000001/providers/Microsoft.Advisor/advisorScore/Cost' `
        -Name 'Cost' `
        -Type 'Microsoft.Advisor/advisorScore' -Props ([PSCustomObject]@{
        lastRefreshedScore = [PSCustomObject]@{ date = '2026-02-20T00:00:00Z'; score = 85.0 }
        timeSeries = @(
            [PSCustomObject]@{
                aggregationLevel = 'Monthly'
                scoreHistory = @(
                    [PSCustomObject]@{ date = '2026-01-01T00:00:00Z'; score = 80.0; impactedResourceCount = 5; consumptionUnits = 1200; potentialScoreIncrease = 3.5 }
                )
            }
        )
    })

    # Support Tickets — module casts createdDate, problemStartTime, modifiedDate to [datetime]
    $script:MockResources += New-MockMgmtResource -Id '/sub/st1' -Name 'ST000001' `
        -Type 'Microsoft.Support/supportTickets' -Props ([PSCustomObject]@{
        severity = 'B'; status = 'open'; problemClassificationDisplayName = 'Azure Virtual Machines'
        createdDate = '2026-02-01T10:00:00Z'; title = 'VM performance degradation'
        serviceDisplayName = 'Virtual Machine running Windows'
        problemStartTime = '2026-01-31T22:00:00Z'
        modifiedDate = '2026-02-05T14:30:00Z'
        supportTicketId = 'ST000001'
        supportPlanType = 'Premier'
        require24X7Response = $false
        serviceLevelAgreement = [PSCustomObject]@{ slaMinutes = 60 }
        supportEngineer = [PSCustomObject]@{ emailAddress = 'support@microsoft.com' }
        contactDetails = [PSCustomObject]@{
            firstName = 'John'; lastName = 'Doe'
            primaryEmailAddress = 'john.doe@contoso.com'; country = 'United States'
        }
    })

    # Reservation Recommendations
    $script:MockResources += New-MockMgmtResource -Id '/sub/rr1' -Name 'RR000001' `
        -Type 'Microsoft.Consumption/reservationRecommendations' -Props ([PSCustomObject]@{
        term = 'P1Y'; recommendedQuantity = 4; normalizedSize = 'Standard_D4s_v3'
        firstUsageDate = '2025-12-01T00:00:00Z'; totalCostWithReservedInstances = 12000
        netSavings = 3000; lookBackPeriod = 'Last7Days'
    })

    # Automation Account
    $script:MockResources += New-MockMgmtResource `
        -Id '/subscriptions/sub-00000001/resourceGroups/rg-mgmt/providers/microsoft.automation/automationaccounts/auto-account-1' `
        -Name 'auto-account-1' -RG 'rg-mgmt' -Location 'eastus' -SubscriptionId 'sub-00000001' `
        -Type 'microsoft.automation/automationaccounts' -Props ([PSCustomObject]@{
        State        = 'Ok'
        sku          = [PSCustomObject]@{ name = 'Free' }
        creationTime = '2025-06-15T10:30:00Z'
    })

    # Runbook (belongs to auto-account-1 via id split)
    $script:MockResources += New-MockMgmtResource `
        -Id '/subscriptions/sub-00000001/resourceGroups/rg-mgmt/providers/microsoft.automation/automationaccounts/auto-account-1/runbooks/test-runbook' `
        -Name 'test-runbook' -RG 'rg-mgmt' -Location 'eastus' -SubscriptionId 'sub-00000001' `
        -Type 'microsoft.automation/automationaccounts/runbooks' -Props ([PSCustomObject]@{
        lastModifiedTime = '2025-07-20T14:00:00Z'
        state            = 'Published'
        runbookType      = 'PowerShell'
        description      = 'Test runbook for automation'
    })

    # Backup Policy
    $script:MockResources += New-MockMgmtResource `
        -Id '/subscriptions/sub-00000001/resourceGroups/rg-mgmt/providers/microsoft.recoveryservices/vaults/backup-vault/backuppolicies/daily-policy' `
        -Name 'daily-policy' -RG 'rg-mgmt' -Location 'eastus' -SubscriptionId 'sub-00000001' `
        -Type 'microsoft.recoveryservices/vaults/backuppolicies' -Props ([PSCustomObject]@{
        workloadtype        = 'AzureIaasVM'
        protecteditemscount = 2
        settings = [PSCustomObject]@{
            iscompression    = $true
            issqlcompression = $false
        }
        subprotectionpolicy = @([PSCustomObject]@{ policytype = 'Full' })
    })

    # Protected Item (linked to daily-policy via policyid)
    $script:MockResources += New-MockMgmtResource `
        -Id '/subscriptions/sub-00000001/resourceGroups/rg-mgmt/providers/microsoft.recoveryservices/vaults/backup-vault/backupFabrics/Azure/protectionContainers/container1/protectedItems/item1' `
        -Name 'test-protected-item' -RG 'rg-mgmt' -Location 'eastus' -SubscriptionId 'sub-00000001' `
        -Type 'microsoft.recoveryservices/vaults/backupfabrics/protectioncontainers/protecteditems' -Props ([PSCustomObject]@{
        policyid                             = '/subscriptions/sub-00000001/resourceGroups/rg-mgmt/providers/microsoft.recoveryservices/vaults/backup-vault/backuppolicies/daily-policy'
        vaultid                              = '/subscriptions/sub-00000001/resourceGroups/rg-mgmt/providers/microsoft.recoveryservices/vaults/backup-vault'
        lastbackuptime                       = '2025-08-01T03:00:00Z'
        lastrecoverypoint                    = '2025-08-01T03:00:00Z'
        latestrecoverypointinsecondaryregion = $null
        backupmanagementtype                 = 'AzureIaasVM'
        friendlyname                         = 'test-vm'
        configuredmaximumretention           = 'P30D'
        configuredrpgenerationfrequency      = 'Daily'
        healthstatus                         = 'Healthy'
        protectionstatus                     = 'Healthy'
        isarchiveenabled                     = $false
        lastbackupstatus                     = 'Completed'
        protectionstate                      = 'Protected'
        protectionstateinsecondaryregion     = $null
        softdeleteretentionperiod            = 14
    })
}

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

# ===================================================================
# TESTS — Resource-based modules
# ===================================================================
Describe 'Management Module Files Exist' {
    It 'Management module folder exists' {
        $script:ManagementPath | Should -Exist
    }

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

    It 'ManagementGroups.ps1 file exists' {
        Join-Path $script:ManagementPath 'ManagementGroups.ps1' | Should -Exist
    }

    It 'CustomRoleDefinitions.ps1 file exists' {
        Join-Path $script:ManagementPath 'CustomRoleDefinitions.ps1' | Should -Exist
    }

    It 'PolicyDefinitions.ps1 file exists' {
        Join-Path $script:ManagementPath 'PolicyDefinitions.ps1' | Should -Exist
    }

    It 'PolicySetDefinitions.ps1 file exists' {
        Join-Path $script:ManagementPath 'PolicySetDefinitions.ps1' | Should -Exist
    }

    It 'PolicyComplianceStates.ps1 file exists' {
        Join-Path $script:ManagementPath 'PolicyComplianceStates.ps1' | Should -Exist
    }

    It 'AllSubscriptions.ps1 file exists' {
        Join-Path $script:ManagementPath 'AllSubscriptions.ps1' | Should -Exist
    }
}

Describe 'Management Module Processing Phase — <Name>' -ForEach $MgmtResourceModules {
    BeforeAll {
        $script:ModFile = Join-Path $script:ManagementPath $File
        $script:ResType = $Type
    }

    It 'Processing returns results when matching resources are present' {
        $matchedResources = $script:MockResources | Where-Object { $_.TYPE -eq $script:ResType }
        if ($matchedResources) {
            $content = Get-Content -Path $script:ModFile -Raw
            $sb = [ScriptBlock]::Create($content)
            $result = Invoke-Command -ScriptBlock $sb -ArgumentList $null, $script:MockSubs, $null, $script:MockResources, $null, 'Processing', $null, $null, 'Light20', $null
            $result | Should -Not -BeNullOrEmpty
        } else {
            Set-ItResult -Skipped -Because "No mock resource of type '$script:ResType'"
        }
    }

    It 'Processing does not throw when given an empty resource list' {
        $content = Get-Content -Path $script:ModFile -Raw
        $sb = [ScriptBlock]::Create($content)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $script:MockSubs, $null, @(), $null, 'Processing', $null, $null, 'Light20', $null } | Should -Not -Throw
    }
}

Describe 'Management Module Reporting Phase — <Name>' -ForEach $MgmtResourceModules {
    BeforeAll {
        $script:ModFile  = Join-Path $script:ManagementPath $File
        $script:ResType  = $Type
        $script:WsName   = $Worksheet
        $script:XlsxFile = Join-Path $script:TempDir ("Mgmt_{0}_{1}.xlsx" -f $Name, [System.IO.Path]::GetRandomFileName())

        $matchedResources = $script:MockResources | Where-Object { $_.TYPE -eq $script:ResType }
        if ($matchedResources) {
            $content = Get-Content -Path $script:ModFile -Raw
            $sb = [ScriptBlock]::Create($content)
            $script:ProcessedData = Invoke-Command -ScriptBlock $sb -ArgumentList $null, $script:MockSubs, $null, $script:MockResources, $null, 'Processing', $null, $null, 'Light20', $null
        } else {
            $script:ProcessedData = $null
        }
    }

    It 'Reporting phase does not throw' {
        if ($script:ProcessedData) {
            $content = Get-Content -Path $script:ModFile -Raw
            $sb = [ScriptBlock]::Create($content)
            { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, $null, $null, 'Reporting', $script:XlsxFile, $script:ProcessedData, 'Light20', $null } | Should -Not -Throw
        } else {
            Set-ItResult -Skipped -Because "No mock resource of type '$script:ResType'"
        }
    }

    It 'Excel file is created' {
        if ($script:ProcessedData) {
            $script:XlsxFile | Should -Exist
        } else {
            Set-ItResult -Skipped -Because "No mock resource of type '$script:ResType'"
        }
    }
}

# ===================================================================
# TESTS — Live-call modules (mock Az cmdlets)
# ===================================================================
Describe 'ManagementGroups — Processing with mocked Get-AzManagementGroup + Get-AzContext' {
    BeforeAll {
        # Build a fake MG hierarchy object matching what Get-AzManagementGroup returns
        $child1 = [PSCustomObject]@{
            Name = 'mg-dept-it'; DisplayName = 'IT Department'; Type = 'Microsoft.Management/managementGroups'
            Children = @([PSCustomObject]@{ Name = 'sub-00000001'; DisplayName = 'Production Sub'; Type = '/subscriptions' })
        }
        $script:FakeMGRoot = [PSCustomObject]@{
            Name        = 'tenant-root-mg-001'
            DisplayName = 'Tenant Root'
            Type        = 'Microsoft.Management/managementGroups'
            Children    = @($child1)
        }
        $script:FakeContext = [PSCustomObject]@{
            Tenant = [PSCustomObject]@{ Id = 'tenant-root-mg-001' }
        }
    }

    It 'Processing does not throw with mocked Get-AzManagementGroup' {
        $modFile = Join-Path $script:ManagementPath 'ManagementGroups.ps1'
        $content = Get-Content -Path $modFile -Raw

        # Inject stub functions after the param() line so the module never calls live Az cmdlets.
        $stubs = @'
 
function Get-AzContext { [PSCustomObject]@{ Tenant = [PSCustomObject]@{ Id = 'tenant-root-mg-001' } } }
function Get-AzManagementGroup { param($GroupId, [switch]$Expand, [switch]$Recurse, $ErrorAction) $null }
 
'@

        $content = $content -replace '(param\([^)]*\))', "`$1`n$stubs"
        $sb = [ScriptBlock]::Create($content)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, @(), $null, 'Processing', $null, $null, 'Light20', $null } | Should -Not -Throw
    }

    It 'Reporting does not throw with empty SmaResources' {
        $modFile  = Join-Path $script:ManagementPath 'ManagementGroups.ps1'
        $content  = Get-Content -Path $modFile -Raw
        $xlsxFile = Join-Path $script:TempDir "ManagementGroups_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 'CustomRoleDefinitions — Processing with mocked Get-AzRoleDefinition' {
    It 'Processing does not throw when Get-AzRoleDefinition fails (no auth)' {
        $modFile = Join-Path $script:ManagementPath 'CustomRoleDefinitions.ps1'
        $content = Get-Content -Path $modFile -Raw
        $stub = "`nfunction Get-AzRoleDefinition { param(`$Name, `$Id, `$Scope, [switch]`$Custom, `$ErrorAction) @() }`n"
        $content = $content -replace '(param\([^)]*\))', "`$1$stub"
        $sb = [ScriptBlock]::Create($content)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, @(), $null, 'Processing', $null, $null, 'Light20', $null } | Should -Not -Throw
    }

    It 'Reporting does not throw with empty data' {
        $modFile  = Join-Path $script:ManagementPath 'CustomRoleDefinitions.ps1'
        $content  = Get-Content -Path $modFile -Raw
        $xlsxFile = Join-Path $script:TempDir "CustomRoles_empty.xlsx"
        $stub = "`nfunction Get-AzRoleDefinition { param(`$Name, `$Id, `$Scope, [switch]`$Custom, `$ErrorAction) @() }`n"
        $content = $content -replace '(param\([^)]*\))', "`$1$stub"
        $sb = [ScriptBlock]::Create($content)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $null, $null, $null, $null, 'Reporting', $xlsxFile, $null, 'Light20', $null } | Should -Not -Throw
    }
}

Describe 'AllSubscriptions — Processing with pre-loaded $Sub data' {
    It 'Processing does not throw with mock subscription list in Resources' {
        $mockSubs = @(
            [PSCustomObject]@{ Id = 'sub-00000001'; Name = 'Prod Sub'; State = 'Enabled'; TenantId = 'tenant-001'; Tags = @{} }
            [PSCustomObject]@{ Id = 'sub-00000002'; Name = 'Dev Sub';  State = 'Enabled'; TenantId = 'tenant-001'; Tags = @{} }
        )
        $modFile = Join-Path $script:ManagementPath 'AllSubscriptions.ps1'
        $content = Get-Content -Path $modFile -Raw
        $sb = [ScriptBlock]::Create($content)
        # Pass mock subs as the $Sub parameter ($Sub is param index 1 = $null placeholder here)
        { Invoke-Command -ScriptBlock $sb -ArgumentList $null, $mockSubs, $null, @(), $null, 'Processing', $null, $null, 'Light20', $null } | Should -Not -Throw
    }
}