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