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 } } } |