Tests/Unit/Get-SPCOrphanedUser.Tests.ps1
|
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } BeforeAll { . (Join-Path $PSScriptRoot '../../Private/Test-SPCConnection.ps1') . (Join-Path $PSScriptRoot '../../Private/Get-SPCRiskLevel.ps1') . (Join-Path $PSScriptRoot '../../Private/Invoke-SPCGraphBatch.ps1') . (Join-Path $PSScriptRoot '../../Public/Scan/Get-SPCOrphanedUser.ps1') # ── Shared fake data ──────────────────────────────────────────────────────── $script:FakeContext = [PSCustomObject]@{ TenantName = 'contoso' AuthMethod = 'Interactive' ConnectedAt = (Get-Date).ToUniversalTime() PnPContext = $null GraphAccessToken = 'fake-graph-token' _ClientId = $null _CertificatePath = $null _CertificatePassword = $null _ClientSecret = $null } # Three UIL users that will be classified as orphaned $script:FakeUILUsers = @( [PSCustomObject]@{ Id = 1; LoginName = 'i:0#.f|membership|alice@contoso.com'; Title = 'Alice'; Email = 'alice@contoso.com' } [PSCustomObject]@{ Id = 2; LoginName = 'i:0#.f|membership|bob@contoso.com'; Title = 'Bob'; Email = 'bob@contoso.com' } [PSCustomObject]@{ Id = 3; LoginName = 'i:0#.f|membership|carol@contoso.com'; Title = 'Carol'; Email = 'carol@contoso.com' } ) # System accounts that must be filtered out $script:SystemAccounts = @( [PSCustomObject]@{ Id = 99; LoginName = 'SHAREPOINT\system'; Title = 'System'; Email = '' } [PSCustomObject]@{ Id = 98; LoginName = 'NT AUTHORITY\authenticated users'; Title = 'Everyone'; Email = '' } [PSCustomObject]@{ Id = 97; LoginName = 'i:0#.f|membership|svc@contoso.com'; Title = 'Service'; Email = '' } # empty email → filtered ) # Graph batch response: all 404 → users are Deleted in Entra function New-DeletedGraphResponse { param([string[]] $ReqIds) $ReqIds | ForEach-Object { [PSCustomObject]@{ id = $_; status = 404; body = $null } } } # Graph batch response: 200 with accountEnabled = true → user is active function New-ActiveGraphResponse { param([string[]] $ReqIds) $ReqIds | ForEach-Object { [PSCustomObject]@{ id = $_ status = 200 body = [PSCustomObject]@{ id = "oid-$_" userPrincipalName = "user$_@contoso.com" accountEnabled = $true displayName = "User $_" } } } } # Soft-deleted check batch response: no results (user is hard-deleted, not soft-deleted) $script:FakeSoftDeletedEmpty = @( [PSCustomObject]@{ id = '1'; status = 200; body = [PSCustomObject]@{ value = @() } } [PSCustomObject]@{ id = '2'; status = 200; body = [PSCustomObject]@{ value = @() } } [PSCustomObject]@{ id = '3'; status = 200; body = [PSCustomObject]@{ value = @() } } ) } Describe 'Get-SPCOrphanedUser' { BeforeEach { $script:SPCContext = $script:FakeContext Mock Connect-PnPOnline { return [PSCustomObject]@{ Url = 'https://fake.sharepoint.com/sites/HR' } } Mock Get-PnPWeb { return [PSCustomObject]@{ Title = 'Human Resources' } } Mock Get-PnPUser { return @() } Mock Get-PnPSiteGroup { return @() } Mock Get-PnPGroupMember { return @() } } Context 'AC-03: orphan detection and classification' { BeforeEach { Mock Get-PnPSiteUser { return $script:FakeUILUsers } # First batch call (user lookups) → all 404 (Deleted) # Second batch call (soft-delete check) → no results → OrphanType = Deleted $callCount = 0 Mock Invoke-SPCGraphBatch { $script:callCount++ if ($script:callCount -eq 1) { return @( [PSCustomObject]@{ id = '1'; status = 404; body = $null } [PSCustomObject]@{ id = '2'; status = 404; body = $null } [PSCustomObject]@{ id = '3'; status = 404; body = $null } ) } else { # Soft-delete check: none found → users are hard-deleted return @( [PSCustomObject]@{ id = '1'; status = 200; body = [PSCustomObject]@{ value = @() } } [PSCustomObject]@{ id = '2'; status = 200; body = [PSCustomObject]@{ value = @() } } [PSCustomObject]@{ id = '3'; status = 200; body = [PSCustomObject]@{ value = @() } } ) } } } It 'AC-03: returns exactly 3 SPC.OrphanedUser objects for 3 orphaned UIL users' { $result = Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR' $result | Should -HaveCount 3 } It 'AC-03: output objects have TypeName SPC.OrphanedUser' { $result = Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR' $result | ForEach-Object { $_.PSObject.TypeNames | Should -Contain 'SPC.OrphanedUser' } } It 'AC-03: OrphanType = Deleted when user is not in Entra (404) and not soft-deleted' { $result = Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR' $result | ForEach-Object { $_.OrphanType | Should -Be 'Deleted' } } It 'AC-03: SiteUrl and SiteTitle are populated on output objects' { $result = Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR' $result[0].SiteUrl | Should -Be 'https://contoso.sharepoint.com/sites/HR' $result[0].SiteTitle | Should -Be 'Human Resources' } It 'AC-03: all required output properties are present' { $result = Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR' $required = 'SiteUrl','SiteTitle','UserId','LoginName','DisplayName','Email','UPN', 'OrphanType','RiskLevel','HasDirectPermissions','GroupMemberships', 'LastActivityDate','DetectedAt' foreach ($prop in $required) { $result[0].PSObject.Properties.Name | Should -Contain $prop } } } Context 'AC-04: clean site returns no results' { It 'AC-04: returns 0 results when all UIL users are active in Entra (200 + accountEnabled)' { Mock Get-PnPSiteUser { return $script:FakeUILUsers } Mock Invoke-SPCGraphBatch { return @( [PSCustomObject]@{ id = '1'; status = 200; body = [PSCustomObject]@{ id = 'oid1'; accountEnabled = $true; userPrincipalName = 'alice@contoso.com'; displayName = 'Alice' } } [PSCustomObject]@{ id = '2'; status = 200; body = [PSCustomObject]@{ id = 'oid2'; accountEnabled = $true; userPrincipalName = 'bob@contoso.com'; displayName = 'Bob' } } [PSCustomObject]@{ id = '3'; status = 200; body = [PSCustomObject]@{ id = 'oid3'; accountEnabled = $true; userPrincipalName = 'carol@contoso.com'; displayName = 'Carol' } } ) } $result = @(Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR') $result | Should -HaveCount 0 } It 'AC-04: returns 0 results when UIL is empty' { Mock Get-PnPSiteUser { return @() } Mock Invoke-SPCGraphBatch {} $result = @(Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR') $result | Should -HaveCount 0 } } Context 'AC-09: system account filtering' { It 'AC-09: SHAREPOINT\system is excluded from results' { Mock Get-PnPSiteUser { return $script:SystemAccounts } Mock Invoke-SPCGraphBatch { return @() } $result = @(Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR') $result | Should -HaveCount 0 } It 'AC-09: membership claim with empty email is treated as service account and excluded' { $svcOnly = @( [PSCustomObject]@{ Id = 97; LoginName = 'i:0#.f|membership|svc@contoso.com'; Title = 'Service'; Email = '' } ) Mock Get-PnPSiteUser { return $svcOnly } Mock Invoke-SPCGraphBatch { return @() } $result = @(Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR') $result | Should -HaveCount 0 } It 'AC-09: Invoke-SPCGraphBatch is called for real (non-system) users' { Mock Get-PnPSiteUser { return $script:FakeUILUsers } Mock Invoke-SPCGraphBatch { return @() } Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR' | Out-Null Should -Invoke Invoke-SPCGraphBatch -Times 1 -Minimum } It 'AC-09: ThrottleLimit below 1 is clamped to 1 with a warning' { Mock Get-PnPSiteUser { return @() } Mock Invoke-SPCGraphBatch { return @() } $warnings = Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR' ` -ThrottleLimit 0 -WarningVariable wv 3>&1 | Out-Null $wv | Should -Match 'clamped to 1' } It 'AC-09: ThrottleLimit above 10 is clamped to 10 with a warning' { Mock Get-PnPSiteUser { return @() } Mock Invoke-SPCGraphBatch { return @() } Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR' ` -ThrottleLimit 99 -WarningVariable wv | Out-Null $wv | Should -Match 'clamped to 10' } } Context 'AC-12: no credentials in output streams' { It 'AC-12: verbose output does not contain credential strings' { Mock Get-PnPSiteUser { return @() } Mock Invoke-SPCGraphBatch { return @() } $out = & { $script:SPCContext = $script:FakeContext Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR' -Verbose 4>&1 5>&1 } 2>&1 $out | ForEach-Object { [string]$_ } | Should -Not -Match 'password|secret|pfx|credential' } } } |