Tests/Unit/Restore-SPCOrphanedUser.Tests.ps1
|
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } BeforeAll { $moduleRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') $publicDir = Join-Path $moduleRoot 'Public' $privateDir = Join-Path $moduleRoot 'Private' . (Join-Path $privateDir 'PnPWrappers.ps1') . (Join-Path $privateDir 'Test-SPCConnection.ps1') . (Join-Path $privateDir 'LicenseManager.ps1') if (Test-Path (Join-Path $publicDir 'License\Get-SPCLicenseInfo.ps1')) { . (Join-Path $publicDir 'License\Get-SPCLicenseInfo.ps1') } if (Test-Path (Join-Path $privateDir 'Connect-SPCSiteInternal.ps1')) { . (Join-Path $privateDir 'Connect-SPCSiteInternal.ps1') } . (Join-Path $publicDir 'Remediate\Restore-SPCOrphanedUser.ps1') # Standardized mock module context schema $script:FakeContext = [PSCustomObject]@{ TenantName = 'contoso' TenantId = 'tid-12345' IsConnected = $true AuthMethod = 'Interactive' ConnectedAt = (Get-Date).ToUniversalTime() GraphTokenRefreshedAt = (Get-Date).ToUniversalTime() PnPContext = [PSCustomObject]@{ Url = 'https://contoso-admin.sharepoint.com' } GraphAccessToken = 'mock_access_token' _ClientId = 'mock_client_id' _CertificateThumbprint = $null _CertificatePath = $null _CertificatePassword = $null _ClientSecret = $null } } Describe 'Restore-SPCOrphanedUser' { BeforeEach { $script:SPCContext = $script:FakeContext Mock Test-SPCConnection {} Mock Assert-SPCProLicense {} Mock Get-PnPAccessToken { return 'mock_token' } Mock Connect-PnPOnline { return [PSCustomObject]@{ Url = 'https://contoso.sharepoint.com/sites/HR' } } Mock Get-PnPWeb { return [PSCustomObject]@{ Title = 'Human Resources' } } Mock Add-PnPRoleAssignment {} Mock Add-PnPGroupMember {} Mock Set-PnPTenantSite {} Mock Remove-PnPSiteCollectionAdmin {} } Context 'Input Validation & Guard Scenarios' { It 'Throws when target snapshot file does not exist' { $nonExistentPath = Join-Path $TestDrive 'non_existent_snapshot.json' { Restore-SPCOrphanedUser -SnapshotPath $nonExistentPath } | Should -Throw "*Snapshot file not found*" } It 'Throws when snapshot is missing required top-level schema fields' { $invalidJson = @{ invalidField = "test" } | ConvertTo-Json $invalidSnapPath = Join-Path $TestDrive 'invalid_schema.json' Set-Content -Path $invalidSnapPath -Value $invalidJson -Encoding UTF8 { Restore-SPCOrphanedUser -SnapshotPath $invalidSnapPath } | Should -Throw "*missing required fields*" } It 'AC-ARCH-02: Blocks execution when Pro license check fails' { Mock Assert-SPCProLicense { throw 'ERR-LIC-001: Feature RestoreSnapshot requires an active Pro license.' } $snapPath = Join-Path $TestDrive 'valid_snap.json' @{ siteUrl = 'https://contoso.sharepoint.com/sites/HR'; user = @{ loginName = 'i:0#.f|membership|user@contoso.com'; upn = 'user@contoso.com'; displayName = 'User' }; permissions = @() } | ConvertTo-Json | Set-Content -Path $snapPath -Encoding UTF8 { Restore-SPCOrphanedUser -SnapshotPath $snapPath } | Should -Throw '*ERR-LIC-001*' } } Context 'AC-ARCH-05 / AC-TEST-01: Snapshot Schema Compatibility (v1.0 & v1.1)' { It 'AC-ARCH-05: Restores permissions and group memberships from v1.0 snapshot format' { $snapV10 = [ordered]@{ snapshotVersion = '1.0' siteUrl = 'https://contoso.sharepoint.com/sites/HR' user = @{ loginName = 'i:0#.f|membership|alice@contoso.com' displayName = 'Alice Smith' upn = 'alice@contoso.com' } permissions = @( @{ scope = 'https://contoso.sharepoint.com/sites/HR'; permissionLevel = 'Edit'; inheritanceStatus = 'Direct' } ) groupMemberships = @( @{ groupId = 10; groupName = 'HR Members' } ) } | ConvertTo-Json -Depth 5 $snapPath = Join-Path $TestDrive 'snap_v10.json' Set-Content -Path $snapPath -Value $snapV10 -Encoding UTF8 $result = Restore-SPCOrphanedUser -SnapshotPath $snapPath -Confirm:$false $result.Status | Should -Be 'Success' $result.PermissionsRestored | Should -Be 2 $result.PermissionsFailed | Should -Be 0 $result.UPN | Should -Be 'alice@contoso.com' $result.SiteUrl | Should -Be 'https://contoso.sharepoint.com/sites/HR' $result.PSObject.TypeNames | Should -Contain 'SPC.RestoreResult' Should -Invoke Add-PnPRoleAssignment -Times 1 -Exactly Should -Invoke Add-PnPGroupMember -Times 1 -Exactly } It 'AC-ARCH-05: Restores permissions from v1.1 snapshot format' { $snapV11 = [ordered]@{ '$schema' = 'https://m365automation.com/schemas/spclean/snapshot-v1.1.json' snapshotVersion = '1.1' createdAt = (Get-Date).ToUniversalTime().ToString('o') tenantName = 'contoso' siteUrl = 'https://contoso.sharepoint.com/sites/Finance' user = @{ loginName = 'i:0#.f|membership|bob@contoso.com' displayName = 'Bob Jones' upn = 'bob@contoso.com' } isEmptyPermissionSet = $false permissions = @( @{ scope = 'https://contoso.sharepoint.com/sites/Finance'; permissionLevel = 'Read'; inheritanceStatus = 'Direct' }, @{ scope = 'https://contoso.sharepoint.com/sites/Finance'; permissionLevel = 'Contribute'; inheritanceStatus = 'Direct' } ) groupMemberships = @() } | ConvertTo-Json -Depth 5 $snapPath = Join-Path $TestDrive 'snap_v11.json' Set-Content -Path $snapPath -Value $snapV11 -Encoding UTF8 $result = Restore-SPCOrphanedUser -SnapshotPath $snapPath -Confirm:$false $result.Status | Should -Be 'Success' $result.PermissionsRestored | Should -Be 2 $result.PermissionsFailed | Should -Be 0 Should -Invoke Add-PnPRoleAssignment -Times 2 -Exactly } It 'AC-ARCH-05: Handles empty permission snapshots with sentinel __empty or empty array gracefully' { $snapEmpty = [ordered]@{ snapshotVersion = '1.1' siteUrl = 'https://contoso.sharepoint.com/sites/HR' user = @{ loginName = 'i:0#.f|membership|carol@contoso.com' displayName = 'Carol Danvers' upn = 'carol@contoso.com' } isEmptyPermissionSet = $true permissions = @() groupMemberships = @() } | ConvertTo-Json -Depth 5 $snapPath = Join-Path $TestDrive 'snap_empty.json' Set-Content -Path $snapPath -Value $snapEmpty -Encoding UTF8 $result = Restore-SPCOrphanedUser -SnapshotPath $snapPath -Confirm:$false $result.Status | Should -Be 'Success' $result.PermissionsRestored | Should -Be 0 $result.PermissionsFailed | Should -Be 0 Should -Invoke Add-PnPRoleAssignment -Times 0 -Exactly Should -Invoke Add-PnPGroupMember -Times 0 -Exactly } } Context 'AC-TEST-01 / NFR-REL-03: Partial Success & Error Recovery' { It 'NFR-REL-03: Returns PartialSuccess when one role assignment fails while others succeed' { $snap = [ordered]@{ snapshotVersion = '1.1' siteUrl = 'https://contoso.sharepoint.com/sites/HR' user = @{ loginName = 'i:0#.f|membership|david@contoso.com' displayName = 'David Miller' upn = 'david@contoso.com' } permissions = @( @{ scope = 'https://contoso.sharepoint.com/sites/HR'; permissionLevel = 'CustomRole1'; inheritanceStatus = 'Direct' }, @{ scope = 'https://contoso.sharepoint.com/sites/HR'; permissionLevel = 'Read'; inheritanceStatus = 'Direct' } ) groupMemberships = @() } | ConvertTo-Json -Depth 5 $snapPath = Join-Path $TestDrive 'snap_partial.json' Set-Content -Path $snapPath -Value $snap -Encoding UTF8 $script:partialCallIndex = 0 Mock Add-PnPRoleAssignment { $script:partialCallIndex++ if ($script:partialCallIndex -eq 1) { throw 'Role definition CustomRole1 not found' } } $result = Restore-SPCOrphanedUser -SnapshotPath $snapPath -Confirm:$false $result.Status | Should -Be 'PartialSuccess' $result.PermissionsRestored | Should -Be 1 $result.PermissionsFailed | Should -Be 1 $result.ErrorMessage | Should -Match 'CustomRole1' } } Context 'ShouldProcess & WhatIf Execution' { It 'AC-TEST-01: WhatIf mode previews restoration without invoking mutation cmdlets' { $snap = [ordered]@{ snapshotVersion = '1.1' siteUrl = 'https://contoso.sharepoint.com/sites/HR' user = @{ loginName = 'i:0#.f|membership|eve@contoso.com' displayName = 'Eve Adams' upn = 'eve@contoso.com' } permissions = @( @{ scope = 'https://contoso.sharepoint.com/sites/HR'; permissionLevel = 'Edit'; inheritanceStatus = 'Direct' } ) groupMemberships = @( @{ groupId = 5; groupName = 'HR Editors' } ) } | ConvertTo-Json -Depth 5 $snapPath = Join-Path $TestDrive 'snap_whatif.json' Set-Content -Path $snapPath -Value $snap -Encoding UTF8 $result = Restore-SPCOrphanedUser -SnapshotPath $snapPath -WhatIf $result.Status | Should -Be 'WhatIf' Should -Invoke Add-PnPRoleAssignment -Times 0 -Exactly Should -Invoke Add-PnPGroupMember -Times 0 -Exactly } } Context 'Security & Stream Isolation' { It 'AC-SEC-02: Does not leak tokens or sensitive credentials into Verbose stream' { $snap = [ordered]@{ snapshotVersion = '1.1' siteUrl = 'https://contoso.sharepoint.com/sites/HR' user = @{ loginName = 'i:0#.f|membership|frank@contoso.com' displayName = 'Frank Castle' upn = 'frank@contoso.com' } permissions = @( @{ scope = 'https://contoso.sharepoint.com/sites/HR'; permissionLevel = 'Read'; inheritanceStatus = 'Direct' } ) groupMemberships = @() } | ConvertTo-Json -Depth 5 $snapPath = Join-Path $TestDrive 'snap_sec.json' Set-Content -Path $snapPath -Value $snap -Encoding UTF8 $script:verboseLog = @() Mock Write-Verbose { param($Message) $script:verboseLog += $Message } Restore-SPCOrphanedUser -SnapshotPath $snapPath -Confirm:$false -Verbose $leak = $script:verboseLog | Where-Object { $_ -match 'password|secret|token|bearer|ey[A-Za-z0-9_-]+' } $leak | Should -BeNullOrEmpty } } } |