Tests/Unit/Get-SPCPrivilegedUser.Tests.ps1

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

Describe 'Get-SPCPrivilegedUser' {
    BeforeAll {
        $script:sut = "$PSScriptRoot\..\..\Public\Scan\Get-SPCPrivilegedUser.ps1"
        . $PSScriptRoot\..\..\Public\Scan\Get-SPCPrivilegedUser.ps1
        function Test-SPCConnection {}
        function Get-PnPTenantSite {}
        function Connect-PnPOnline {}
        function Get-PnPSiteCollectionAdmin {}
        function Invoke-PnPSPRestMethod {}
        function Get-PnPGroup {}
        function Get-PnPGroupMember {}
        function Start-Sleep {}
    }

    BeforeEach {
        $script:SPCContext = [PSCustomObject]@{
            TenantName = 'tenant'
            AuthMethod = 'Interactive'
            PnPContext = $null
        }
    }

    Context 'When scanning sites' {
        It 'AC-F2-01 Should return top 20 privileged users grouped by UPN' {
            Mock Get-PnPTenantSite {
                $sites = @()
                for ($i=1; $i -le 25; $i++) {
                    $sites += [PSCustomObject]@{ Url = "https://tenant.sharepoint.com/sites/site$i" }
                }
                return $sites
            }
            Mock Get-PnPAccessToken { return "MockToken" }
            Mock Connect-PnPOnline { return "MockConnection" }
            
            Mock Get-PnPSiteCollectionAdmin {
                $users = @()
                for ($u=1; $u -le 25; $u++) {
                    # Every user is SCA on site corresponding to their number and below
                    $users += [PSCustomObject]@{ LoginName = "i:0#.f|membership|user$u@domain.com" }
                }
                return $users
            }
            Mock Get-PnPGroup { return $null }
            Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } }

            $result = Get-SPCPrivilegedUser
            $result.Count | Should -Be 20
            $result[0].UPN | Should -Match "user"
        }
    }

    Context 'When checking permission sources' {
        It 'AC-F2-02 Should correctly identify SCA, Owner, and Direct Full Control' {
            Mock Get-PnPTenantSite { return @([PSCustomObject]@{ Url = "https://tenant.sharepoint.com/sites/site1" }) }
            Mock Get-PnPAccessToken { return "MockToken" }
            Mock Connect-PnPOnline { return "MockConnection" }
            Mock Get-PnPSiteCollectionAdmin { return @([PSCustomObject]@{ LoginName = "i:0#.f|membership|usera@domain.com" }) }
            Mock Get-PnPGroup { return [PSCustomObject]@{ Title = "Owners" } }
            Mock Get-PnPGroupMember { return @([PSCustomObject]@{ LoginName = "i:0#.f|membership|userb@domain.com" }) }
            Mock Invoke-PnPSPRestMethod {
                return [PSCustomObject]@{
                    value = @(
                        [PSCustomObject]@{ 
                            Member = [PSCustomObject]@{ PrincipalType = 'User'; LoginName = "i:0#.f|membership|userc@domain.com" }
                            RoleDefinitionBindings = @([PSCustomObject]@{ Name = 'Full Control' })
                        }
                    )
                }
            }

            $result = Get-SPCPrivilegedUser
            $result.Count | Should -Be 3
            
            $usera = $result | Where-Object { $_.UPN -eq 'usera@domain.com' }
            $usera.PermissionSources | Should -Contain 'SCA'
            
            $userb = $result | Where-Object { $_.UPN -eq 'userb@domain.com' }
            $userb.PermissionSources | Should -Contain 'Owner'

            $userc = $result | Where-Object { $_.UPN -eq 'userc@domain.com' }
            $userc.PermissionSources | Should -Contain 'Direct'
        }
    }

    Context 'When no privileged user exists' {
        It 'AC-F2-03 Should return empty list gracefully' {
            Mock Get-PnPTenantSite { return @([PSCustomObject]@{ Url = "https://tenant.sharepoint.com/sites/site1" }) }
            Mock Get-PnPAccessToken { return "MockToken" }
            Mock Connect-PnPOnline { return "MockConnection" }
            Mock Get-PnPSiteCollectionAdmin { return @() }
            Mock Get-PnPGroup { return $null }
            Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } }

            $result = Get-SPCPrivilegedUser
            $result | Should -BeNullOrEmpty
        }
    }

    Context 'Negative Scenarios' {
        It 'AC-NEG-01 Should throw Terminating Error ERR-AUTH-001 when connection fails' {
            Mock Get-PnPTenantSite { throw "Connection failed" }
            
            { Get-SPCPrivilegedUser } | Should -Throw -ErrorId "ERR-AUTH-001*"
        }

        It 'AC-NEG-02 Should write non-terminating error and continue for inaccessible sites' {
            Mock Get-PnPTenantSite { 
                return @(
                    [PSCustomObject]@{ Url = "https://tenant.sharepoint.com/sites/errorSite" },
                    [PSCustomObject]@{ Url = "https://tenant.sharepoint.com/sites/goodSite" }
                ) 
            }
            Mock Get-PnPAccessToken { return "MockToken" }
            Mock Connect-PnPOnline { 
                if ($args[0] -match "errorSite") {
                    throw "Access Denied"
                }
                return "MockConnection"
            }
            Mock Get-PnPSiteCollectionAdmin { return @([PSCustomObject]@{ LoginName = "i:0#.f|membership|good@domain.com" }) }
            Mock Get-PnPGroup { return $null }
            Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } }

            $result = Get-SPCPrivilegedUser -ErrorAction SilentlyContinue
            $result.Count | Should -Be 1
            $result[0].UPN | Should -Be "good@domain.com"
        }

        It 'AC-NEG-03 Should implement exponential backoff retry on 429/503 errors' {
            Mock Get-PnPTenantSite { return @([PSCustomObject]@{ Url = "https://tenant.sharepoint.com/sites/site1" }) }
            $global:retryCount = 0
            Mock Get-PnPAccessToken { return "MockToken" }
            Mock Connect-PnPOnline { 
                $global:retryCount++
                if ($global:retryCount -lt 3) {
                    throw "429 Too Many Requests"
                }
                return "MockConnection"
            }
            Mock Get-PnPSiteCollectionAdmin { return @() }
            Mock Get-PnPGroup { return $null }
            Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } }
            Mock Start-Sleep { return $true } # Mock Start-Sleep to speed up test

            $result = Get-SPCPrivilegedUser
            $global:retryCount | Should -Be 3
        }
    }
    
    Context 'Security Scenarios' {
        It 'AC-SEC-01 Should not leak credentials in verbose stream' {
            Mock Get-PnPTenantSite { return @([PSCustomObject]@{ Url = "https://tenant.sharepoint.com/sites/site1" }) }
            Mock Get-PnPAccessToken { return "MockToken" }
            Mock Connect-PnPOnline { return "MockConnection" }
            Mock Get-PnPSiteCollectionAdmin { return @() }
            Mock Get-PnPGroup { return $null }
            Mock Invoke-PnPSPRestMethod { return [PSCustomObject]@{ value = @() } }

            $verboseOutput = ""
            try {
                Get-SPCPrivilegedUser -Verbose 4>&1 | ForEach-Object { $verboseOutput += $_.ToString() }
            } catch { }

            $verboseOutput | Should -Not -Match 'password|secret|token|pfx|credential'
        }
        
        It 'AC-SEC-03 Should not execute dynamic string via IEX' {
            $scriptContent = Get-Content $script:sut -Raw
            $scriptContent -match 'iex|Invoke-Expression' | Should -BeFalse
        }
    }
}