Tests/Unit/Remove-SPCOrphanedUser.Tests.ps1

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

BeforeAll {
    . (Join-Path $PSScriptRoot '../../Private/Test-SPCConnection.ps1')
    . (Join-Path $PSScriptRoot '../../Private/Save-SPCPermissionSnapshot.ps1')
    . (Join-Path $PSScriptRoot '../../Public/Remediate/Remove-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
    }

    function New-FakeOrphan {
        param(
            [string] $UPN          = 'jdoe@contoso.com',
            [string] $OrphanType   = 'Deleted',
            [string] $RiskLevel    = 'HIGH',
            [bool]   $DirectPerms  = $true,
            [string] $SiteUrl      = 'https://contoso.sharepoint.com/sites/HR'
        )
        $o = [PSCustomObject][ordered]@{
            SiteUrl              = $SiteUrl
            SiteTitle            = 'Human Resources'
            UserId               = 42
            LoginName            = "i:0#.f|membership|$UPN"
            DisplayName          = 'John Doe'
            Email                = $UPN
            UPN                  = $UPN
            OrphanType           = $OrphanType
            RiskLevel            = $RiskLevel
            HasDirectPermissions = $DirectPerms
            GroupMemberships     = @('HR Members')
            LastActivityDate     = $null
            DetectedAt           = (Get-Date).ToUniversalTime()
        }
        $o.PSObject.TypeNames.Insert(0, 'SPC.OrphanedUser')
        $o
    }
}

Describe 'Remove-SPCOrphanedUser' {

    BeforeEach {
        $script:SPCContext = $script:FakeContext

        Mock Connect-PnPOnline           { return [PSCustomObject]@{ Url = 'fake' } }
        Mock Remove-PnPUser              {}
        Mock Get-PnPRoleAssignment       { return @() }
        Mock Remove-PnPRoleAssignment    {}
        Mock Save-SPCPermissionSnapshot  { return [System.IO.FileInfo]::new('C:\fake\snap.json') }
    }

    Context 'AC-06: WhatIf writes exact SRS message, no changes made' {
        It 'AC-06: WhatIf writes exact SRS message to information stream' {
            $orphan = New-FakeOrphan -UPN 'jdoe@contoso.com' -SiteUrl 'https://contoso.sharepoint.com/sites/HR'
            $info   = $orphan | Remove-SPCOrphanedUser -WhatIf -InformationAction Continue 6>&1
            $info   | Should -Match "WhatIf: Would remove user John Doe \(jdoe@contoso\.com\) from site https://contoso\.sharepoint\.com/sites/HR\. OrphanType: Deleted\. DirectPermissions: True\."
        }

        It 'AC-06: WhatIf does not call Remove-PnPUser' {
            $orphan = New-FakeOrphan
            $orphan | Remove-SPCOrphanedUser -WhatIf -InformationAction SilentlyContinue | Out-Null
            Should -Invoke Remove-PnPUser -Times 0 -Exactly
        }

        It 'AC-06: WhatIf does not call Save-SPCPermissionSnapshot even with -CreateSnapshot' {
            $orphan = New-FakeOrphan
            $orphan | Remove-SPCOrphanedUser -WhatIf -CreateSnapshot -SnapshotPath 'C:\fake' `
                -InformationAction SilentlyContinue | Out-Null
            Should -Invoke Save-SPCPermissionSnapshot -Times 0 -Exactly
        }
    }

    Context 'AC-06: OrphanType filter defaults to Deleted only' {
        It 'AC-06: SoftDeleted orphans are skipped by default' {
            $softDeleted = New-FakeOrphan -OrphanType 'SoftDeleted'
            $softDeleted | Remove-SPCOrphanedUser -Force | Out-Null
            Should -Invoke Remove-PnPUser -Times 0 -Exactly
        }

        It 'AC-06: Disabled orphans are skipped by default' {
            $disabled = New-FakeOrphan -OrphanType 'Disabled'
            $disabled | Remove-SPCOrphanedUser -Force | Out-Null
            Should -Invoke Remove-PnPUser -Times 0 -Exactly
        }

        It 'AC-06: Deleted orphans are processed by default' {
            $deleted = New-FakeOrphan -OrphanType 'Deleted'
            $deleted | Remove-SPCOrphanedUser -Force | Out-Null
            Should -Invoke Remove-PnPUser -Times 1 -Exactly
        }

        It 'AC-06: SoftDeleted is processed when explicitly opted in' {
            $softDeleted = New-FakeOrphan -OrphanType 'SoftDeleted' -RiskLevel 'MEDIUM'
            $softDeleted | Remove-SPCOrphanedUser -OrphanType 'SoftDeleted' -Force | Out-Null
            Should -Invoke Remove-PnPUser -Times 1 -Exactly
        }
    }

    Context 'AC-06: CreateSnapshot calls Save-SPCPermissionSnapshot before removal' {
        It 'AC-06: Save-SPCPermissionSnapshot is called when -CreateSnapshot is set' {
            $orphan = New-FakeOrphan
            $orphan | Remove-SPCOrphanedUser -CreateSnapshot -SnapshotPath 'C:\fake' -Force | Out-Null
            Should -Invoke Save-SPCPermissionSnapshot -Times 1 -Exactly
        }

        It 'AC-06: SnapshotPath is populated in SPC.RemovalResult when -CreateSnapshot used' {
            $orphan = New-FakeOrphan
            $result = $orphan | Remove-SPCOrphanedUser -CreateSnapshot -SnapshotPath 'C:\fake' -Force
            $result.SnapshotPath | Should -Not -BeNullOrEmpty
        }

        It 'AC-06: SnapshotPath is null in SPC.RemovalResult without -CreateSnapshot' {
            $orphan = New-FakeOrphan
            $result = $orphan | Remove-SPCOrphanedUser -Force
            $result.SnapshotPath | Should -BeNullOrEmpty
        }
    }

    Context 'AC-06: SPC.RemovalResult output' {
        It 'AC-06: output has TypeName SPC.RemovalResult' {
            $orphan = New-FakeOrphan
            $result = $orphan | Remove-SPCOrphanedUser -Force
            $result.PSObject.TypeNames | Should -Contain 'SPC.RemovalResult'
        }

        It 'AC-06: Status = Success when UIL removal succeeds' {
            $orphan = New-FakeOrphan
            $result = $orphan | Remove-SPCOrphanedUser -Force
            $result.Status | Should -Be 'Success'
        }

        It 'AC-06: Status = Failed when Remove-PnPUser throws' {
            Mock Remove-PnPUser { throw 'Access denied' }
            $orphan = New-FakeOrphan
            $result = $orphan | Remove-SPCOrphanedUser -Force 2>$null
            $result.Status        | Should -Be 'Failed'
            $result.ErrorMessage  | Should -Match 'Access denied'
        }

        It 'AC-06: RemovedFromUIL = $true on success' {
            $orphan = New-FakeOrphan
            $result = $orphan | Remove-SPCOrphanedUser -Force
            $result.RemovedFromUIL | Should -BeTrue
        }

        It 'AC-06: summary is written to information stream' {
            $orphan = New-FakeOrphan
            $info   = $orphan | Remove-SPCOrphanedUser -Force -InformationAction Continue 6>&1
            $info   | Should -Match 'Removed \d+ orphaned users across \d+ sites'
        }
    }

    Context 'RiskLevel filter' {
        It 'AC-06: LOW orphan is skipped when -RiskLevel HIGH is specified' {
            $low = New-FakeOrphan -RiskLevel 'LOW'
            $low | Remove-SPCOrphanedUser -RiskLevel 'HIGH' -Force | Out-Null
            Should -Invoke Remove-PnPUser -Times 0 -Exactly
        }

        It 'AC-06: HIGH orphan is processed when -RiskLevel HIGH is specified' {
            $high = New-FakeOrphan -RiskLevel 'HIGH'
            $high | Remove-SPCOrphanedUser -RiskLevel 'HIGH' -Force | Out-Null
            Should -Invoke Remove-PnPUser -Times 1 -Exactly
        }
    }

    Context 'AC-12: no credentials in output streams' {
        It 'AC-12: verbose output does not contain credential strings' {
            $orphan = New-FakeOrphan
            $out    = & {
                $script:SPCContext = $script:FakeContext
                $orphan | Remove-SPCOrphanedUser -Force -Verbose 4>&1 5>&1
            } 2>&1
            $out | ForEach-Object { [string]$_ } |
                Should -Not -Match 'password|secret|pfx|credential'
        }
    }
}