Tests/Private/Backend.Tests.ps1

BeforeDiscovery {
    Import-Module (Join-Path $PSScriptRoot '../../Posh-SecretRotation.psd1') -Force
}

Describe 'Private/Backend' -Tag Unit {
    InModuleScope 'Posh-SecretRotation' {
        Describe 'Set-SecretRotationADPassword' {
            BeforeAll {
                # RSAT is not installed in this dev environment, so Set-ADAccountPassword does not
                # exist as a real command anywhere on the machine - this stub exists only so Mock
                # has something to attach to and -ParameterFilter has real parameters to bind against.
                function script:Set-ADAccountPassword {
                    # -ErrorAction is not redeclared here: CmdletBinding() already supplies it as
                    # a common parameter, and PSScriptAnalyzer's PSReservedParams rule rejects an
                    # explicit parameter with that reserved name.
                    [CmdletBinding()]
                    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Test stub mirroring the real RSAT cmdlet signature so Mock -ParameterFilter can bind against it - never actually used in a body.')]
                    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'NewPassword', Justification = 'Mirrors the real Set-ADAccountPassword signature for mocking purposes only; never invoked for real.')]
                    param($Identity, $NewPassword, [switch] $Reset, $Server)
                }
            }

            It 'throws when Password is not a SecureString' {
                { Set-SecretRotationADPassword -Identifier 'svc1' -Password 'plaintext' -BackendConfig @{} } |
                    Should -Throw '*SecureString*'
            }

            It 'throws a clear, actionable error when the ActiveDirectory module is not installed' {
                Mock Get-Module { $null } -ParameterFilter { $Name -eq 'ActiveDirectory' -and $ListAvailable }

                $securePassword = New-Object System.Security.SecureString
                { Set-SecretRotationADPassword -Identifier 'svc1' -Password $securePassword -BackendConfig @{} } |
                    Should -Throw '*ActiveDirectory*'
            }

            It 'calls Set-ADAccountPassword with -Reset, the identity, and -Server from backendConfig.forest' {
                Mock Get-Module { [PSCustomObject]@{ Name = 'ActiveDirectory' } } -ParameterFilter { $Name -eq 'ActiveDirectory' -and $ListAvailable }
                Mock Import-Module {}
                Mock Set-ADAccountPassword {}

                $securePassword = New-Object System.Security.SecureString
                Set-SecretRotationADPassword -Identifier 'svc1' -Password $securePassword -BackendConfig @{ forest = 'corp.local' }

                Should -Invoke Set-ADAccountPassword -Times 1 -Exactly -ParameterFilter {
                    $Identity -eq 'svc1' -and $Reset -eq $true -and $Server -eq 'corp.local'
                }
            }

            It 'omits -Server when backendConfig has no forest' {
                Mock Get-Module { [PSCustomObject]@{ Name = 'ActiveDirectory' } } -ParameterFilter { $Name -eq 'ActiveDirectory' -and $ListAvailable }
                Mock Import-Module {}
                Mock Set-ADAccountPassword {}

                $securePassword = New-Object System.Security.SecureString
                Set-SecretRotationADPassword -Identifier 'svc1' -Password $securePassword -BackendConfig @{}

                Should -Invoke Set-ADAccountPassword -Times 1 -Exactly -ParameterFilter {
                    $null -eq $Server
                }
            }

            It 'propagates the backend exception on failure (never swallows it)' {
                Mock Get-Module { [PSCustomObject]@{ Name = 'ActiveDirectory' } } -ParameterFilter { $Name -eq 'ActiveDirectory' -and $ListAvailable }
                Mock Import-Module {}
                Mock Set-ADAccountPassword { throw 'Access is denied' }

                $securePassword = New-Object System.Security.SecureString
                { Set-SecretRotationADPassword -Identifier 'svc1' -Password $securePassword -BackendConfig @{} } |
                    Should -Throw '*Access is denied*'
            }
        }

        Describe 'Set-SecretRotationEntraPassword' {
            BeforeAll {
                # The Microsoft Graph PowerShell SDK is not installed in this dev environment, so
                # Update-MgUser does not exist as a real command anywhere on the machine - this
                # stub exists only so Mock has something to attach to and -ParameterFilter has
                # real parameters to bind against.
                function script:Update-MgUser {
                    [CmdletBinding()]
                    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Test stub mirroring the real Microsoft Graph cmdlet signature so Mock -ParameterFilter can bind against it - never actually used in a body.')]
                    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingUsernameAndPasswordParams', '', Justification = 'Mirrors the real Update-MgUser signature (-UserId, -PasswordProfile) for mocking purposes only; never invoked for real.')]
                    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'PasswordProfile', Justification = 'Mirrors the real Update-MgUser signature for mocking purposes only; never invoked for real.')]
                    param($UserId, $PasswordProfile)
                }
            }

            It 'throws when Password is not a String' {
                { Set-SecretRotationEntraPassword -Identifier 'svc2@corp.onmicrosoft.com' -Password (New-Object System.Security.SecureString) -BackendConfig @{} } |
                    Should -Throw '*String*'
            }

            It 'throws a clear, actionable error when Microsoft.Graph.Users is not installed' {
                Mock Get-Module { $null } -ParameterFilter { $Name -eq 'Microsoft.Graph.Users' -and $ListAvailable }

                { Set-SecretRotationEntraPassword -Identifier 'svc2@corp.onmicrosoft.com' -Password 'plaintext-password' -BackendConfig @{} } |
                    Should -Throw '*Microsoft.Graph.Users*'
            }

            It 'calls Update-MgUser with the identity and a PasswordProfile defaulting ForceChangePasswordNextSignIn to false' {
                Mock Get-Module { [PSCustomObject]@{ Name = 'Microsoft.Graph.Users' } } -ParameterFilter { $Name -eq 'Microsoft.Graph.Users' -and $ListAvailable }
                Mock Import-Module {}
                Mock Update-MgUser {}

                Set-SecretRotationEntraPassword -Identifier 'svc2@corp.onmicrosoft.com' -Password 'plaintext-password' -BackendConfig @{}

                Should -Invoke Update-MgUser -Times 1 -Exactly -ParameterFilter {
                    $UserId -eq 'svc2@corp.onmicrosoft.com' -and
                    $PasswordProfile.Password -eq 'plaintext-password' -and
                    $PasswordProfile.ForceChangePasswordNextSignIn -eq $false
                }
            }

            It 'honors an explicit forceChangePasswordNextSignIn backendConfig override' {
                Mock Get-Module { [PSCustomObject]@{ Name = 'Microsoft.Graph.Users' } } -ParameterFilter { $Name -eq 'Microsoft.Graph.Users' -and $ListAvailable }
                Mock Import-Module {}
                Mock Update-MgUser {}

                Set-SecretRotationEntraPassword -Identifier 'svc2@corp.onmicrosoft.com' -Password 'plaintext-password' -BackendConfig @{ forceChangePasswordNextSignIn = $true }

                Should -Invoke Update-MgUser -Times 1 -Exactly -ParameterFilter {
                    $PasswordProfile.ForceChangePasswordNextSignIn -eq $true
                }
            }

            It 'propagates the backend exception on failure (never swallows it)' {
                Mock Get-Module { [PSCustomObject]@{ Name = 'Microsoft.Graph.Users' } } -ParameterFilter { $Name -eq 'Microsoft.Graph.Users' -and $ListAvailable }
                Mock Import-Module {}
                Mock Update-MgUser { throw 'Insufficient privileges' }

                { Set-SecretRotationEntraPassword -Identifier 'svc2@corp.onmicrosoft.com' -Password 'plaintext-password' -BackendConfig @{} } |
                    Should -Throw '*Insufficient privileges*'
            }
        }

        Describe 'Invoke-SecretRotationCustomHandler' {
            BeforeAll {
                $script:handlerDir = Join-Path ([System.IO.Path]::GetTempPath()) "SecretRotationCustomHandlerTest-$([guid]::NewGuid())"
                New-Item -ItemType Directory -Path $script:handlerDir -Force | Out-Null

                $script:goodHandlerPath = Join-Path $script:handlerDir 'Set-GoodHandler.ps1'
                @'
function Set-GoodHandler {
    param(
        [Parameter(Mandatory)] [string] $Identifier,
        [Parameter(Mandatory)] $Password,
        [Parameter(Mandatory)] [hashtable] $BackendConfig
    )
    [PSCustomObject]@{ Identifier = $Identifier; Password = $Password; BackendConfigFoo = $BackendConfig.foo } |
        ConvertTo-Json | Set-Content -Path $BackendConfig.testOutputPath -Encoding UTF8
}
'@
 | Set-Content -Path $script:goodHandlerPath -Encoding UTF8

                $script:throwingHandlerPath = Join-Path $script:handlerDir 'Set-ThrowingHandler.ps1'
                @'
function Set-ThrowingHandler {
    param($Identifier, $Password, $BackendConfig)
    throw 'vault rejected the new password'
}
'@
 | Set-Content -Path $script:throwingHandlerPath -Encoding UTF8

                $script:noFunctionPath = Join-Path $script:handlerDir 'Set-NoFunction.ps1'
                'Write-Verbose "does not define the expected function"' | Set-Content -Path $script:noFunctionPath -Encoding UTF8
            }

            AfterAll {
                Remove-Item -Path $script:handlerDir -Recurse -Force -ErrorAction SilentlyContinue
            }

            It 'throws when customHandler.scriptPath is missing' {
                $customHandler = [PSCustomObject]@{ functionName = 'Set-GoodHandler' }
                { Invoke-SecretRotationCustomHandler -Identifier 'x' -Password 'p' -BackendConfig @{} -CustomHandler $customHandler -ConfigDirectory $script:handlerDir } |
                    Should -Throw '*scriptPath*'
            }

            It 'throws when customHandler.functionName is missing' {
                $customHandler = [PSCustomObject]@{ scriptPath = 'Set-GoodHandler.ps1' }
                { Invoke-SecretRotationCustomHandler -Identifier 'x' -Password 'p' -BackendConfig @{} -CustomHandler $customHandler -ConfigDirectory $script:handlerDir } |
                    Should -Throw '*functionName*'
            }

            It 'throws when the script file does not exist' {
                $customHandler = [PSCustomObject]@{ scriptPath = 'does-not-exist.ps1'; functionName = 'Set-GoodHandler' }
                { Invoke-SecretRotationCustomHandler -Identifier 'x' -Password 'p' -BackendConfig @{} -CustomHandler $customHandler -ConfigDirectory $script:handlerDir } |
                    Should -Throw '*was not found*'
            }

            It 'throws naming the missing function when the script does not define it' {
                $customHandler = [PSCustomObject]@{ scriptPath = 'Set-NoFunction.ps1'; functionName = 'Set-DoesNotExist' }
                { Invoke-SecretRotationCustomHandler -Identifier 'x' -Password 'p' -BackendConfig @{} -CustomHandler $customHandler -ConfigDirectory $script:handlerDir } |
                    Should -Throw '*Set-DoesNotExist*'
            }

            It 'resolves a relative scriptPath against ConfigDirectory and calls the function with Identifier/Password/BackendConfig' {
                $outputPath = Join-Path $script:handlerDir "output-$([guid]::NewGuid()).json"
                $customHandler = [PSCustomObject]@{ scriptPath = 'Set-GoodHandler.ps1'; functionName = 'Set-GoodHandler' }

                Invoke-SecretRotationCustomHandler -Identifier 'vault-secret-1' -Password 'plain-password' `
                    -BackendConfig @{ foo = 'bar'; testOutputPath = $outputPath } -CustomHandler $customHandler -ConfigDirectory $script:handlerDir

                $result = Get-Content -Path $outputPath -Raw | ConvertFrom-Json
                $result.Identifier | Should -Be 'vault-secret-1'
                $result.Password | Should -Be 'plain-password'
                $result.BackendConfigFoo | Should -Be 'bar'
            }

            It 'accepts an absolute scriptPath unchanged, regardless of ConfigDirectory' {
                $outputPath = Join-Path $script:handlerDir "output-$([guid]::NewGuid()).json"
                $customHandler = [PSCustomObject]@{ scriptPath = $script:goodHandlerPath; functionName = 'Set-GoodHandler' }

                { Invoke-SecretRotationCustomHandler -Identifier 'x' -Password 'p' -BackendConfig @{ testOutputPath = $outputPath } -CustomHandler $customHandler -ConfigDirectory 'C:\some\unrelated\directory' } |
                    Should -Not -Throw
            }

            It 'propagates the handler''s thrown exception (never swallows it)' {
                $customHandler = [PSCustomObject]@{ scriptPath = 'Set-ThrowingHandler.ps1'; functionName = 'Set-ThrowingHandler' }
                { Invoke-SecretRotationCustomHandler -Identifier 'x' -Password 'p' -BackendConfig @{} -CustomHandler $customHandler -ConfigDirectory $script:handlerDir } |
                    Should -Throw '*vault rejected the new password*'
            }

            It 'lets two different custom targets use same-named functions from different scripts without colliding' {
                $dirA = Join-Path $script:handlerDir 'targetA'
                $dirB = Join-Path $script:handlerDir 'targetB'
                New-Item -ItemType Directory -Path $dirA, $dirB -Force | Out-Null

                'function Set-SharedName { param($Identifier, $Password, $BackendConfig) "A: $Identifier" | Set-Content -Path $BackendConfig.testOutputPath }' |
                    Set-Content -Path (Join-Path $dirA 'handler.ps1') -Encoding UTF8
                'function Set-SharedName { param($Identifier, $Password, $BackendConfig) "B: $Identifier" | Set-Content -Path $BackendConfig.testOutputPath }' |
                    Set-Content -Path (Join-Path $dirB 'handler.ps1') -Encoding UTF8

                $outputA = Join-Path $script:handlerDir 'outA.txt'
                $outputB = Join-Path $script:handlerDir 'outB.txt'

                Invoke-SecretRotationCustomHandler -Identifier 'idA' -Password 'p' -BackendConfig @{ testOutputPath = $outputA } `
                    -CustomHandler ([PSCustomObject]@{ scriptPath = (Join-Path $dirA 'handler.ps1'); functionName = 'Set-SharedName' }) -ConfigDirectory $script:handlerDir
                Invoke-SecretRotationCustomHandler -Identifier 'idB' -Password 'p' -BackendConfig @{ testOutputPath = $outputB } `
                    -CustomHandler ([PSCustomObject]@{ scriptPath = (Join-Path $dirB 'handler.ps1'); functionName = 'Set-SharedName' }) -ConfigDirectory $script:handlerDir

                (Get-Content -Path $outputA -Raw).Trim() | Should -Be 'A: idA'
                (Get-Content -Path $outputB -Raw).Trim() | Should -Be 'B: idB'
            }
        }

        Describe 'Set-SecretRotationBackendPassword' {
            It 'dispatches ActiveDirectory targets to Set-SecretRotationADPassword' {
                Mock Set-SecretRotationADPassword {}
                $securePassword = New-Object System.Security.SecureString

                Set-SecretRotationBackendPassword -Backend 'ActiveDirectory' -Identifier 'svc1' -Password $securePassword -BackendConfig @{}

                Should -Invoke Set-SecretRotationADPassword -Times 1 -Exactly -ParameterFilter { $Identifier -eq 'svc1' }
            }

            It 'dispatches EntraID targets to Set-SecretRotationEntraPassword' {
                Mock Set-SecretRotationEntraPassword {}

                Set-SecretRotationBackendPassword -Backend 'EntraID' -Identifier 'svc2@corp.onmicrosoft.com' -Password 'x' -BackendConfig @{}

                Should -Invoke Set-SecretRotationEntraPassword -Times 1 -Exactly -ParameterFilter { $Identifier -eq 'svc2@corp.onmicrosoft.com' }
            }

            It 'dispatches Custom targets to Invoke-SecretRotationCustomHandler' {
                Mock Invoke-SecretRotationCustomHandler {}
                $customHandler = [PSCustomObject]@{ scriptPath = 'x.ps1'; functionName = 'Set-X' }

                Set-SecretRotationBackendPassword -Backend 'Custom' -Identifier 'vault1' -Password 'x' -BackendConfig @{} `
                    -CustomHandler $customHandler -ConfigDirectory 'C:\config'

                Should -Invoke Invoke-SecretRotationCustomHandler -Times 1 -Exactly -ParameterFilter {
                    $Identifier -eq 'vault1' -and $ConfigDirectory -eq 'C:\config'
                }
            }

            It 'throws a clear config error for Custom when customHandler is missing' {
                { Set-SecretRotationBackendPassword -Backend 'Custom' -Identifier 'vault1' -Password 'x' -BackendConfig @{} } |
                    Should -Throw '*customHandler*'
            }

            It 'throws a clear error naming an unknown backend' {
                { Set-SecretRotationBackendPassword -Backend 'Bogus' -Identifier 'svc1' -Password 'x' -BackendConfig @{} } |
                    Should -Throw '*Bogus*'
            }
        }
    }
}