Tests/Set-Secret.Tests.ps1

BeforeAll {
    Get-Module 'SecretManagement.CyberArk' | Remove-Module -Force
    Get-Module 'Microsoft.Powershell.SecretManagement' | Remove-Module -Force

    $ExtensionModule = Import-Module "$PSScriptRoot/../SecretManagement.CyberArk.Extension/*.psd1" -Force -PassThru
    $VaultName = 'CyberArk.Test'
}

AfterAll {
    Remove-Module $ExtensionModule -Force
}

Describe 'Set-Secret' {
    Context 'when connection method is REST' {
        BeforeAll {
            $VaultParams = @{ConnectionType = 'REST' }
            Mock Get-PASSession -MockWith { [PSCustomObject]@{ BaseURI = 'https://pvwa' } } -ModuleName $ExtensionModule.Name
        }

        It 'calls Add-PASAccount' {
            Mock Add-PASAccount -MockWith {} -ModuleName $ExtensionModule.Name

            Set-Secret -VaultName $VaultName -Name 'test' -AdditionalParameters ($VaultParams + @{PlatformId = 'Test'; SafeName = 'TestSafe' }) -Secret ('test' | ConvertTo-SecureString -AsPlainText -Force)
            Should -Invoke -CommandName Add-PASAccount -ModuleName $ExtensionModule.Name
        }

        It 'writes an error when SafeName and PlatformId are missing' {
            Mock Add-PASAccount -MockWith {} -ModuleName $ExtensionModule.Name

            Set-Secret -VaultName $VaultName -AdditionalParameters $VaultParams -Name 'test' -Secret ('test' | ConvertTo-SecureString -AsPlainText -Force) -ErrorAction SilentlyContinue -ErrorVariable Err
            "$Err" | Should -BeLike '*SafeName and PlatformId are required*'
            Should -Invoke -CommandName Add-PASAccount -ModuleName $ExtensionModule.Name -Times 0 -Exactly
        }

    }

    Context 'when connection method is REST and Metadata is supplied' {
        BeforeAll {
            Mock Get-PASSession -MockWith { [PSCustomObject]@{ BaseURI = 'https://pvwa' } } -ModuleName $ExtensionModule.Name
            $Plain = 'Zq9-UniqueSecretValue-81!'
            $Sec = ConvertTo-SecureString $Plain -AsPlainText -Force
            $Full = @{ SafeName = 'MSafe'; PlatformId = 'MPlat'; address = 'm.host'; userName = 'muser' }
        }

        BeforeEach {
            Mock Add-PASAccount -MockWith {} -ModuleName $ExtensionModule.Name
        }

        It 'takes all four values from Metadata when vault parameters lack them' {
            Set-Secret -Name 'n' -Secret $Sec -VaultName $VaultName -AdditionalParameters @{ConnectionType = 'REST' } -Metadata $Full
            Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 1 -Exactly -ParameterFilter {
                $SafeName -eq 'MSafe' -and $platformID -eq 'MPlat' -and $address -eq 'm.host' -and $userName -eq 'muser' -and $name -eq 'n'
            }
        }

        It 'keeps working from vault parameters only' {
            $vp = @{ConnectionType = 'REST'; SafeName = 'VSafe'; PlatformId = 'VPlat'; address = 'v.host'; userName = 'vuser' }
            Set-Secret -Name 'n' -Secret $Sec -VaultName $VaultName -AdditionalParameters $vp -Metadata @{}
            Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 1 -Exactly -ParameterFilter {
                $SafeName -eq 'VSafe' -and $platformID -eq 'VPlat' -and $address -eq 'v.host' -and $userName -eq 'vuser'
            }
        }

        It 'lets Metadata win per key over vault parameters' {
            $vp = @{ConnectionType = 'REST'; SafeName = 'VSafe'; PlatformId = 'VPlat'; address = 'v.host'; userName = 'vuser' }
            Set-Secret -Name 'n' -Secret $Sec -VaultName $VaultName -AdditionalParameters $vp -Metadata $Full
            Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 1 -Exactly -ParameterFilter {
                $SafeName -eq 'MSafe' -and $platformID -eq 'MPlat' -and $address -eq 'm.host' -and $userName -eq 'muser'
            }
        }

        It 'mixes sources: SafeName from Metadata, PlatformId and userName from vault' {
            $vp = @{ConnectionType = 'REST'; SafeName = 'VSafe'; PlatformId = 'VPlat'; userName = 'vuser' }
            Set-Secret -Name 'n' -Secret $Sec -VaultName $VaultName -AdditionalParameters $vp -Metadata @{ SafeName = 'MSafe' }
            Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 1 -Exactly -ParameterFilter {
                $SafeName -eq 'MSafe' -and $platformID -eq 'VPlat' -and $userName -eq 'vuser' -and -not $PesterBoundParameters.ContainsKey('address')
            }
        }

        It 'falls back to vault parameters when a Metadata value is empty' {
            $vp = @{ConnectionType = 'REST'; SafeName = 'VSafe'; PlatformId = 'VPlat' }
            Set-Secret -Name 'n' -Secret $Sec -VaultName $VaultName -AdditionalParameters $vp -Metadata @{ SafeName = '' }
            Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 1 -Exactly -ParameterFilter { $SafeName -eq 'VSafe' }
        }

        It 'writes an error naming <Missing> and does not call Add-PASAccount' -ForEach @(
            @{ Meta = @{ PlatformId = 'P' }; Vp = @{ConnectionType = 'REST' }; Missing = 'SafeName' }
            @{ Meta = @{ SafeName = 'S' }; Vp = @{ConnectionType = 'REST' }; Missing = 'PlatformId' }
            @{ Meta = @{}; Vp = @{ConnectionType = 'REST' }; Missing = 'SafeName, PlatformId' }
        ) {
            Set-Secret -Name 'n' -Secret $Sec -VaultName $VaultName -AdditionalParameters $Vp -Metadata $Meta -ErrorAction SilentlyContinue -ErrorVariable Err
            "$Err" | Should -BeLike "*missing*$Missing*"
            Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 0 -Exactly
        }

        It 'accepts case variants of keys in Metadata' -ForEach @('safename', 'SAFENAME', 'SafeName') {
            $m = @{ PLATFORMID = 'P'; ADDRESS = 'a'; USERNAME = 'u' }
            $m[$_] = 'CaseSafe'
            Set-Secret -Name 'n' -Secret $Sec -VaultName $VaultName -AdditionalParameters @{ConnectionType = 'REST' } -Metadata $m
            Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 1 -Exactly -ParameterFilter {
                $SafeName -eq 'CaseSafe' -and $platformID -eq 'P' -and $address -eq 'a' -and $userName -eq 'u'
            }
        }

        It 'accepts case variants of keys in case-sensitive AdditionalParameters' -ForEach @('safename', 'SAFENAME', 'SafeName') {
            $vp = [System.Collections.Hashtable]::new([System.StringComparer]::Ordinal)
            $vp['ConnectionType'] = 'REST'; $vp['platformid'] = 'P'; $vp['ADDRESS'] = 'a'; $vp['USERNAME'] = 'u'
            $vp[$_] = 'CaseSafe'
            Set-Secret -Name 'n' -Secret $Sec -VaultName $VaultName -AdditionalParameters $vp -Metadata @{}
            Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 1 -Exactly -ParameterFilter {
                $SafeName -eq 'CaseSafe' -and $platformID -eq 'P' -and $address -eq 'a' -and $userName -eq 'u'
            }
        }

        It 'ignores unknown Metadata keys, names them only as keys, and never leaks the secret' {
            $m = $Full + @{ Colour = 'blue'; Owner = 'bob' }
            $out = Set-Secret -Name 'n' -Secret $Sec -VaultName $VaultName -AdditionalParameters @{ConnectionType = 'REST'; Verbose = $true } -Metadata $m -Verbose -WarningAction Continue *>&1 | Out-String
            Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 1 -Exactly -ParameterFilter {
                -not $PesterBoundParameters.ContainsKey('Colour') -and -not $PesterBoundParameters.ContainsKey('Owner') -and $SafeName -eq 'MSafe'
            }
            $out | Should -BeLike '*Colour*'
            $out | Should -BeLike '*Owner*'
            $out | Should -Not -BeLike '*blue*'
            $out | Should -Not -BeLike "*$Plain*"
        }

        It 'omits name when no Name is given' {
            Set-Secret -Secret $Sec -VaultName $VaultName -AdditionalParameters @{ConnectionType = 'REST' } -Metadata $Full
            Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 1 -Exactly -ParameterFilter { -not $PesterBoundParameters.ContainsKey('name') }
        }

        It 'names unknown keys in verbose output on a direct -Verbose call' {
            $out = Set-Secret -Name 'n' -Secret $Sec -VaultName $VaultName -AdditionalParameters @{ConnectionType = 'REST' } -Metadata ($Full + @{ Bogus = 'x' }) -Verbose 4>&1 | Out-String
            $out | Should -BeLike '*Bogus*'
            $out | Should -Not -BeLike "*$Plain*"
        }

        It 'does not leak the secret in the missing-key error' {
            Set-Secret -Name 'n' -Secret $Sec -VaultName $VaultName -AdditionalParameters @{ConnectionType = 'REST' } -Metadata @{} -ErrorAction SilentlyContinue -ErrorVariable Err -WarningVariable Warn
            "$Err $Warn" | Should -Not -BeLike "*$Plain*"
        }

        Context 'secret types' {
            BeforeAll { $Base = @{ SafeName = 'S'; PlatformId = 'P' } }

            It 'converts a string secret to a SecureString' {
                Set-Secret -Name 'n' -Secret 'plain-pw' -VaultName $VaultName -AdditionalParameters @{ConnectionType = 'REST' } -Metadata $Base
                Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 1 -Exactly -ParameterFilter {
                    $secret -is [securestring] -and [System.Net.NetworkCredential]::new('', $secret).Password -eq 'plain-pw'
                }
            }

            It 'passes a SecureString through' {
                Set-Secret -Name 'n' -Secret $Sec -VaultName $VaultName -AdditionalParameters @{ConnectionType = 'REST' } -Metadata $Base
                Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 1 -Exactly -ParameterFilter {
                    $secret -is [securestring] -and [System.Net.NetworkCredential]::new('', $secret).Password -eq $Plain
                }
            }

            It 'uses the password of a PSCredential and its UserName when userName is not supplied' {
                $Cred = [pscredential]::new('cred-user', $Sec)
                Set-Secret -Name 'n' -Secret $Cred -VaultName $VaultName -AdditionalParameters @{ConnectionType = 'REST' } -Metadata $Base
                Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 1 -Exactly -ParameterFilter {
                    $secret -is [securestring] -and [System.Net.NetworkCredential]::new('', $secret).Password -eq $Plain -and $userName -eq 'cred-user'
                }
            }

            It 'prefers a userName from Metadata over the PSCredential UserName' {
                $Cred = [pscredential]::new('cred-user', $Sec)
                Set-Secret -Name 'n' -Secret $Cred -VaultName $VaultName -AdditionalParameters @{ConnectionType = 'REST' } -Metadata ($Base + @{ userName = 'meta-user' })
                Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 1 -Exactly -ParameterFilter { $userName -eq 'meta-user' }
            }

            It 'writes an error without calling Add-PASAccount for an unsupported secret type and never echoes the value' {
                Set-Secret -Name 'n' -Secret @{ pw = $Plain } -VaultName $VaultName -AdditionalParameters @{ConnectionType = 'REST' } -Metadata $Base -ErrorAction SilentlyContinue -ErrorVariable Err
                "$Err" | Should -BeLike '*secret type*'
                "$Err" | Should -Not -BeLike "*$Plain*"
                Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 0 -Exactly
            }
        }

        It 'writes an error with the exception message and no secret when Add-PASAccount throws' {
            Mock Add-PASAccount -MockWith { throw 'safe is full' } -ModuleName $ExtensionModule.Name
            Set-Secret -Name 'n' -Secret $Sec -VaultName $VaultName -AdditionalParameters @{ConnectionType = 'REST' } -Metadata $Full -ErrorAction SilentlyContinue -ErrorVariable Err
            "$Err" | Should -BeLike '*n*safe is full*'
            "$Err" | Should -Not -BeLike "*$Plain*"
        }

        It 'works when called exactly as SecretManagement calls it' {
            $params = @{
                Name                 = 'n'
                Secret               = $Sec
                VaultName            = $VaultName
                AdditionalParameters = @{ConnectionType = 'REST'; Verbose = $false }
                Metadata             = $Full
            }
            Set-Secret @params
            Should -Invoke Add-PASAccount -ModuleName $ExtensionModule.Name -Times 1 -Exactly -ParameterFilter { $SafeName -eq 'MSafe' -and $secret }
        }

    }

    Context 'when connection type is Credential Provider' {
        It 'writes an error' {
            $VaultParams = @{ConnectionType = 'CredentialProvider' }
            Set-Secret -Name 'admin' -VaultName $VaultName -AdditionalParameters $VaultParams -ErrorAction SilentlyContinue -ErrorVariable Err
            "$Err" | Should -BeLike '*Set-Secret is not supported for Credential Provider*'
        }
    }

    Context 'when connection type is Central Credential Provider' {
        It 'writes an error' {
            $VaultParams = @{ConnectionType = 'CentralCredentialProvider' }
            Set-Secret -Name 'admin' -VaultName $VaultName -AdditionalParameters $VaultParams -ErrorAction SilentlyContinue -ErrorVariable Err
            "$Err" | Should -BeLike '*Set-Secret is not supported for Central Credential Provider*'
        }
    }
}