tests/PermissionAudit.Tests.ps1

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

<#
.SYNOPSIS
    Pester tests for the -PermissionAudit switch and Invoke-AZSCPermissionAudit function.
 
.DESCRIPTION
    Validates:
      - The -PermissionAudit and -IncludeEntraPermissions switches exist on Invoke-AzureScout
      - Invoke-AZSCPermissionAudit function signature (parameters, OutputFormat ValidateSet)
      - Return object structure when function is invoked with no Azure context
      - IncludeEntraPermissions switch is exposed and documented in the function
    No live Azure authentication is required.
 
.NOTES
    Author: AzureScout Contributors
    Version: 1.0.0
    Created: 2026-02-24
    Phase: 20 — Permission Audit Feature
#>


BeforeAll {
    $script:ModuleRoot      = Split-Path -Parent $PSScriptRoot
    $script:InvokeScript    = Join-Path $script:ModuleRoot 'Modules' 'Public' 'PublicFunctions' 'Invoke-AzureScout.ps1'
    $script:AuditScript     = Join-Path $script:ModuleRoot 'Modules' 'Private' 'Main' 'Invoke-AZTIPermissionAudit.ps1'

    # Dot-source both scripts to inspect function metadata
    . $script:InvokeScript
    . $script:AuditScript

    $script:InvokeCmd = Get-Command -Name Invoke-AzureScout         -ErrorAction SilentlyContinue
    $script:AuditCmd  = Get-Command -Name Invoke-AZSCPermissionAudit -ErrorAction SilentlyContinue
}

# ===================================================================
# Switch parameters on Invoke-AzureScout
# ===================================================================
Describe 'PermissionAudit switches on Invoke-AzureScout' {
    It 'Invoke-AzureScout function is available' {
        $script:InvokeCmd | Should -Not -BeNullOrEmpty
    }

    It 'PermissionAudit switch parameter exists' {
        $script:InvokeCmd.Parameters.ContainsKey('PermissionAudit') | Should -BeTrue
    }

    It 'PermissionAudit is a [switch] type' {
        $script:InvokeCmd.Parameters['PermissionAudit'].ParameterType | Should -Be ([System.Management.Automation.SwitchParameter])
    }

    It 'PermissionAudit has alias "AuditPermissions"' {
        $aliases = $script:InvokeCmd.Parameters['PermissionAudit'].Aliases
        $aliases | Should -Contain 'AuditPermissions'
    }

    It 'PermissionAudit has alias "CheckPermissions"' {
        $aliases = $script:InvokeCmd.Parameters['PermissionAudit'].Aliases
        $aliases | Should -Contain 'CheckPermissions'
    }

    It 'IncludeEntraPermissions switch parameter exists' {
        $script:InvokeCmd.Parameters.ContainsKey('IncludeEntraPermissions') | Should -BeTrue
    }

    It 'IncludeEntraPermissions is a [switch] type' {
        $script:InvokeCmd.Parameters['IncludeEntraPermissions'].ParameterType | Should -Be ([System.Management.Automation.SwitchParameter])
    }

    It 'IncludeEntraPermissions has alias "EntraAudit"' {
        $aliases = $script:InvokeCmd.Parameters['IncludeEntraPermissions'].Aliases
        $aliases | Should -Contain 'EntraAudit'
    }

    It 'IncludeEntraPermissions has alias "CheckEntraPermissions"' {
        $aliases = $script:InvokeCmd.Parameters['IncludeEntraPermissions'].Aliases
        $aliases | Should -Contain 'CheckEntraPermissions'
    }
}

# ===================================================================
# Invoke-AZSCPermissionAudit function signature
# ===================================================================
Describe 'Invoke-AZSCPermissionAudit — Function Signature' {
    It 'Invoke-AZSCPermissionAudit function is available after dot-source' {
        $script:AuditCmd | Should -Not -BeNullOrEmpty
    }

    It 'IncludeEntraPermissions switch parameter exists' {
        $script:AuditCmd.Parameters.ContainsKey('IncludeEntraPermissions') | Should -BeTrue
    }

    It 'IncludeEntraPermissions is a [switch] type' {
        $script:AuditCmd.Parameters['IncludeEntraPermissions'].ParameterType | Should -Be ([System.Management.Automation.SwitchParameter])
    }

    It 'TenantID parameter exists' {
        $script:AuditCmd.Parameters.ContainsKey('TenantID') | Should -BeTrue
    }

    It 'TenantID is a [string] type' {
        $script:AuditCmd.Parameters['TenantID'].ParameterType | Should -Be ([string])
    }

    It 'OutputFormat parameter exists' {
        $script:AuditCmd.Parameters.ContainsKey('OutputFormat') | Should -BeTrue
    }

    It 'OutputFormat ValidateSet contains "Console"' {
        $vs = $script:AuditCmd.Parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
        $vs.ValidValues | Should -Contain 'Console'
    }

    It 'OutputFormat ValidateSet contains "Markdown"' {
        $vs = $script:AuditCmd.Parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
        $vs.ValidValues | Should -Contain 'Markdown'
    }

    It 'OutputFormat ValidateSet contains "AsciiDoc"' {
        $vs = $script:AuditCmd.Parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
        $vs.ValidValues | Should -Contain 'AsciiDoc'
    }

    It 'OutputFormat ValidateSet contains "Json"' {
        $vs = $script:AuditCmd.Parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
        $vs.ValidValues | Should -Contain 'Json'
    }

    It 'OutputFormat ValidateSet contains "All"' {
        $vs = $script:AuditCmd.Parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
        $vs.ValidValues | Should -Contain 'All'
    }

    It 'OutputFormat default value is "Console"' {
        $default = $script:AuditCmd.Parameters['OutputFormat'].DefaultValue
        if ($null -eq $default) {
            Set-ItResult -Skipped -Because 'Default not accessible via reflection'
        } else {
            $default | Should -Be 'Console'
        }
    }

    It 'ReportDir parameter exists' {
        $script:AuditCmd.Parameters.ContainsKey('ReportDir') | Should -BeTrue
    }

    It 'SubscriptionID parameter exists' {
        $script:AuditCmd.Parameters.ContainsKey('SubscriptionID') | Should -BeTrue
    }

    It 'SubscriptionID accepts an array of strings' {
        $script:AuditCmd.Parameters['SubscriptionID'].ParameterType | Should -Be ([string[]])
    }
}

# ===================================================================
# No-auth graceful behavior
# ===================================================================
Describe 'Invoke-AZSCPermissionAudit — No Azure Context Behavior' {
    It 'Returns $null when no Azure context is present (no auth)' {
        # The function calls Get-AzContext internally; without auth it returns $null
        # and the function returns $null early with an error message — not a throw
        $result = Invoke-AZSCPermissionAudit -ErrorAction SilentlyContinue 2>$null
        # If no context, function should return null (not throw)
        # If somehow context IS present (CI with auth), this still validates the result type
        if ($null -ne $result) {
            $result | Should -BeOfType [PSCustomObject]
        } else {
            $result | Should -BeNullOrEmpty
        }
    }

    It 'Does not throw terminating error when Get-AzContext returns $null' {
        { Invoke-AZSCPermissionAudit -ErrorAction SilentlyContinue 2>$null } | Should -Not -Throw
    }
}

# ===================================================================
# Return object structure (source analysis)
# ===================================================================
Describe 'Invoke-AZSCPermissionAudit — Return Object Structure (Source Analysis)' {
    BeforeAll {
        $script:AuditSource = Get-Content -Path $script:AuditScript -Raw
    }

    It 'Source defines ArmAccess in the return object' {
        $script:AuditSource | Should -Match 'ArmAccess'
    }

    It 'Source defines GraphAccess in the return object' {
        $script:AuditSource | Should -Match 'GraphAccess'
    }

    It 'Source defines CallerAccount in the return object' {
        $script:AuditSource | Should -Match 'CallerAccount'
    }

    It 'Source defines OverallReadiness in the return object' {
        $script:AuditSource | Should -Match 'OverallReadiness'
    }

    It 'Source defines ArmDetails in the return object' {
        $script:AuditSource | Should -Match 'armDetails|ArmDetails'
    }

    It 'Source defines ProviderResults in the return object' {
        $script:AuditSource | Should -Match 'providerResults|ProviderResults'
    }

    It 'Source defines GraphDetails in the return object' {
        $script:AuditSource | Should -Match 'graphDetails|GraphDetails'
    }

    It 'Source defines Recommendations in the return object' {
        $script:AuditSource | Should -Match 'recommendations|Recommendations'
    }

    It 'OverallReadiness uses switch expression with Insufficient state' {
        $script:AuditSource | Should -Match 'Insufficient'
    }

    It 'OverallReadiness includes FullARM state' {
        $script:AuditSource | Should -Match 'FullARM'
    }

    It 'OverallReadiness includes FullARMAndEntra state' {
        $script:AuditSource | Should -Match 'FullARMAndEntra'
    }

    It 'Source has separate ARM path and Entra path (IncludeEntraPermissions guard)' {
        $script:AuditSource | Should -Match 'IncludeEntraPermissions'
    }
}

# ===================================================================
# Invoke-AzureScout source-level PermissionAudit routing
# ===================================================================
Describe 'PermissionAudit routing in Invoke-AzureScout source' {
    BeforeAll {
        $script:InvokeSource = Get-Content -Path $script:InvokeScript -Raw
    }

    It 'Invoke-AzureScout calls Invoke-AZSCPermissionAudit when -PermissionAudit is set' {
        $script:InvokeSource | Should -Match 'Invoke-AZSCPermissionAudit'
    }

    It 'Source passes IncludeEntraPermissions to the audit function' {
        $script:InvokeSource | Should -Match 'IncludeEntraPermissions'
    }

    It 'Source passes SubscriptionID to the audit function' {
        $script:InvokeSource | Should -Match 'Invoke-AZSCPermissionAudit[\s\S]*-SubscriptionID'
    }

    It 'Source has early-return or conditional block that skips inventory when PermissionAudit is used' {
        $script:InvokeSource | Should -Match 'PermissionAudit'
    }
}

# ===================================================================
# Entra/Graph audit path — null/scalar-safe .Count regression tests
#
# Root cause: Get-AzSubscription/Where-Object/Select-Object collapse a SINGLE
# matching result to a bare scalar object instead of an array. Calling .Count
# on that scalar is silently coerced to 1 by PowerShell 7's intrinsic
# Count/Length member, but THROWS under strict mode on Windows PowerShell 5.1
# ("The property 'Count' cannot be found on this object") because Desktop
# edition has no such intrinsic. These tests pin the fix (@() wrapping) with
# a fully mocked, offline audit run — no live Azure/Graph access required.
# ===================================================================
Describe 'Invoke-AZSCPermissionAudit — Entra audit survives null/scalar Graph and ARM data' {
    BeforeAll {
        # Bring the private Graph helper functions into scope so they can be mocked
        # (they are dot-sourced by the module but not by this test's BeforeAll).
        . (Join-Path -Path $script:ModuleRoot -ChildPath 'Modules' -AdditionalChildPath 'Private', 'Main', 'Get-AZTIGraphToken.ps1')
        . (Join-Path -Path $script:ModuleRoot -ChildPath 'Modules' -AdditionalChildPath 'Private', 'Main', 'Invoke-AZTIGraphRequest.ps1')

        # Exactly ONE enabled subscription — the precise shape that collapses
        # $subs | Where-Object {...} | Select-Object -First 3 to a scalar.
        $script:FakeSub = [PSCustomObject]@{
            Id    = '11111111-1111-1111-1111-111111111111'
            Name  = 'demo-sub'
            State = 'Enabled'
        }

        Mock -CommandName Get-AzContext -MockWith {
            [PSCustomObject]@{
                Account = [PSCustomObject]@{ Id = 'user@contoso.com'; Type = 'User' }
                Tenant  = [PSCustomObject]@{ Id = '22222222-2222-2222-2222-222222222222' }
            }
        }
        Mock -CommandName Get-AzSubscription -MockWith { $script:FakeSub }
        Mock -CommandName Get-AzRoleAssignment -MockWith {
            [PSCustomObject]@{ RoleDefinitionName = 'Reader' }
        }
        Mock -CommandName Set-AzContext -MockWith { $null }
        Mock -CommandName Get-AzResourceProvider -MockWith {
            [PSCustomObject]@{ RegistrationState = 'Registered' }
        }
        # Graph token acquisition succeeds, but every Graph request returns $null —
        # simulating an empty/absent Graph response rather than a hard failure.
        Mock -CommandName Get-AZSCGraphToken -MockWith { @{ Authorization = 'Bearer faketoken' } }
        Mock -CommandName Invoke-AZSCGraphRequest -MockWith { $null }
    }

    It 'Does not throw when Get-AzSubscription returns a single (scalar-collapsing) subscription and Graph returns $null' {
        # Set-StrictMode is dynamically scoped and propagates into functions called from
        # here (verified against both Windows PowerShell 5.1 and PowerShell 7). Enabling it
        # is what actually exercises the crash condition: without it, a scalar's missing
        # .Count silently evaluates to $null/1 instead of throwing, so this assertion would
        # pass even against the un-fixed code. This is the same failure mode a caller with
        # `Set-StrictMode -Version Latest` in their profile hits on Windows PowerShell 5.1.
        Set-StrictMode -Version Latest
        { $script:Result = Invoke-AZSCPermissionAudit -IncludeEntraPermissions -TenantID '22222222-2222-2222-2222-222222222222' -OutputFormat Console } | Should -Not -Throw
    }

    It 'Returns a populated result object instead of crashing mid-audit' {
        $script:Result | Should -Not -BeNullOrEmpty
        $script:Result.OverallReadiness | Should -Not -BeNullOrEmpty
    }

    It 'GraphDetails is present (an array, possibly empty) rather than the audit crashing before reaching Section 3' {
        , $script:Result.GraphDetails | Should -Not -BeNullOrEmpty
        $script:Result.GraphDetails.Count | Should -BeGreaterOrEqual 0
    }

    It 'ProviderResults reflects the single scalar-collapsing subscription without throwing on .Count' {
        , $script:Result.ProviderResults | Should -Not -BeNullOrEmpty
        $script:Result.ProviderResults.Count | Should -BeGreaterThan 0
    }

    It 'Recommendations is a real array whose .Count never throws even when empty (Sort-Object -Unique null-collapse guard)' {
        { $script:Result.Recommendations.Count } | Should -Not -Throw
    }
}