Tests/Private/PivTool.Tests.ps1

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '',
    Justification = 'Test fixture values, not real secrets; SecureString-typed parameters require a real SecureString even in unit tests.')]
param()

BeforeDiscovery {
    Import-Module (Resolve-Path "$PSScriptRoot\..\..\Posh-YBKPIV.psd1") -Force
}

BeforeAll {
    Import-Module (Resolve-Path "$PSScriptRoot\..\..\Posh-YBKPIV.psd1") -Force
}

AfterAll {
    Remove-Module Posh-YBKPIV -ErrorAction SilentlyContinue
}

InModuleScope Posh-YBKPIV {

    BeforeAll {
        $script:Wrapping = [PSCustomObject]@{
            options = [PSCustomObject]@{
                slot     = [PSCustomObject]@{ cliFlag = '--slot'; type = 'string'; allowedValues = @('9a', '9c', '9d', '9e', 'f9'); allowedPattern = '^(8[2-9]|9[0-5])$' }
                pin      = [PSCustomObject]@{ cliFlag = '--pin'; type = 'secureString' }
                compress = [PSCustomObject]@{ cliFlag = '--compress'; type = 'switch' }
                validDays = [PSCustomObject]@{ cliFlag = '--valid-days'; type = 'int'; default = 365 }
                algorithm = [PSCustomObject]@{ cliFlag = '--algorithm'; type = 'string'; allowedValues = @('RSA2048', 'ECCP256') }
            }
            actions = [PSCustomObject]@{
                'generate' = [PSCustomObject]@{ cliAction = 'generate'; requiredOptions = @('slot'); optionalOptions = @('algorithm', 'compress') }
                'verify-pin' = [PSCustomObject]@{ cliAction = 'verify-pin'; requiredOptions = @('pin'); optionalOptions = @() }
            }
        }
    }

    Describe 'ConvertTo-PWSHYBKPIVParameterName' -Tag Unit {
        It 'Capitalizes the first letter of a camelCase name' {
            ConvertTo-PWSHYBKPIVParameterName -Name 'pinPolicy' | Should -Be 'PinPolicy'
        }

        It 'Handles single-word names' {
            ConvertTo-PWSHYBKPIVParameterName -Name 'reader' | Should -Be 'Reader'
        }

        It 'Handles names with an embedded acronym-like segment' {
            ConvertTo-PWSHYBKPIVParameterName -Name 'objectId' | Should -Be 'ObjectId'
        }

        It 'Prefixes "Tool" onto names colliding with a PowerShell common parameter' {
            ConvertTo-PWSHYBKPIVParameterName -Name 'verbose' | Should -Be 'ToolVerbose'
        }
    }

    Describe 'ConvertFrom-PWSHYBKPIVSecureString' -Tag Unit {
        It 'Returns the plain-text value of a SecureString' {
            $secure = ConvertTo-SecureString -String 'my-secret-pin' -AsPlainText -Force
            ConvertFrom-PWSHYBKPIVSecureString -SecureString $secure | Should -Be 'my-secret-pin'
        }
    }

    Describe 'Get-PWSHYBKPIVActionParameter' -Tag Unit {
        It 'Throws on an unknown action' {
            { Get-PWSHYBKPIVActionParameter -Action 'not-a-real-action' -CmdletWrapping $script:Wrapping } | Should -Throw -ExpectedMessage '*not-a-real-action*'
        }

        It 'Builds one dynamic parameter per required and optional option' {
            $dict = Get-PWSHYBKPIVActionParameter -Action 'generate' -CmdletWrapping $script:Wrapping
            $dict.Keys | Sort-Object | Should -Be @('Algorithm', 'Compress', 'Slot')
        }

        It 'Marks required options as Mandatory and optional options as not' {
            $dict = Get-PWSHYBKPIVActionParameter -Action 'generate' -CmdletWrapping $script:Wrapping
            $dict['Slot'].Attributes[0].Mandatory | Should -BeTrue
            $dict['Algorithm'].Attributes[0].Mandatory | Should -BeFalse
        }

        It 'Maps switch/secureString/default types to the correct .NET type' {
            $generateDict = Get-PWSHYBKPIVActionParameter -Action 'generate' -CmdletWrapping $script:Wrapping
            $generateDict['Compress'].ParameterType | Should -Be ([switch])

            $pinDict = Get-PWSHYBKPIVActionParameter -Action 'verify-pin' -CmdletWrapping $script:Wrapping
            $pinDict['Pin'].ParameterType | Should -Be ([System.Security.SecureString])
        }

        It 'Builds a ValidateSet when only allowedValues is present' {
            $dict = Get-PWSHYBKPIVActionParameter -Action 'generate' -CmdletWrapping $script:Wrapping
            $validateSet = $dict['Algorithm'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
            $validateSet.ValidValues | Should -Be @('RSA2048', 'ECCP256')
        }

        It 'Combines allowedValues and allowedPattern with OR semantics via ValidateScript' {
            $dict = Get-PWSHYBKPIVActionParameter -Action 'generate' -CmdletWrapping $script:Wrapping
            $validateScript = $dict['Slot'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateScriptAttribute] }
            $validateScript | Should -Not -BeNullOrEmpty

            $results = @('9a', '83', 'zz') | ForEach-Object -Process $validateScript.ScriptBlock
            $results | Should -Be @($true, $true, $false)
        }
    }

    Describe 'Invoke-PWSHYBKPIVTool' -Tag Unit {
        BeforeEach {
            $script:FakeToolOk = Join-Path -Path $TestDrive -ChildPath 'fake-tool-ok.cmd'
            Set-Content -Path $script:FakeToolOk -Value "@echo off`r`necho %*`r`nexit /b 0"

            $script:FakeToolFail = Join-Path -Path $TestDrive -ChildPath 'fake-tool-fail.cmd'
            Set-Content -Path $script:FakeToolFail -Value "@echo off`r`necho tool error output`r`nexit /b 1"

            $script:FakeToolStderrOk = Join-Path -Path $TestDrive -ChildPath 'fake-tool-stderr-ok.cmd'
            Set-Content -Path $script:FakeToolStderrOk -Value "@echo off`r`necho Successfully set new management key. 1>&2`r`nexit /b 0"
        }

        It 'Throws on an unknown action' {
            { Invoke-PWSHYBKPIVTool -ExePath $script:FakeToolOk -Action 'not-a-real-action' -CmdletWrapping $script:Wrapping -BoundParameters @{} } | Should -Throw -ExpectedMessage '*not-a-real-action*'
        }

        It 'Throws when a required option is missing from BoundParameters' {
            { Invoke-PWSHYBKPIVTool -ExePath $script:FakeToolOk -Action 'generate' -CmdletWrapping $script:Wrapping -BoundParameters @{} } | Should -Throw -ExpectedMessage '*-Slot*'
        }

        It 'Builds --action plus the required and bound optional flags' {
            $result = Invoke-PWSHYBKPIVTool -ExePath $script:FakeToolOk -Action 'generate' -CmdletWrapping $script:Wrapping -BoundParameters @{ Slot = '9a'; Algorithm = 'ECCP256' }
            $result.ExitCode | Should -Be 0
            ($result.Output -join ' ') | Should -Match '--action generate --slot 9a --algorithm ECCP256'
        }

        It 'Adds a switch flag only when the bound value is true' {
            $result = Invoke-PWSHYBKPIVTool -ExePath $script:FakeToolOk -Action 'generate' -CmdletWrapping $script:Wrapping -BoundParameters @{ Slot = '9a'; Compress = $true }
            ($result.Output -join ' ') | Should -Match '--compress'
        }

        It 'Omits the switch flag when the bound value is false' {
            $result = Invoke-PWSHYBKPIVTool -ExePath $script:FakeToolOk -Action 'generate' -CmdletWrapping $script:Wrapping -BoundParameters @{ Slot = '9a'; Compress = $false }
            ($result.Output -join ' ') | Should -Not -Match '--compress'
        }

        It 'Converts a SecureString option to plain text before adding it to the argument list' {
            $securePin = ConvertTo-SecureString -String '123456' -AsPlainText -Force
            $result = Invoke-PWSHYBKPIVTool -ExePath $script:FakeToolOk -Action 'verify-pin' -CmdletWrapping $script:Wrapping -BoundParameters @{ Pin = $securePin }
            ($result.Output -join ' ') | Should -Match '--pin 123456'
        }

        It 'Throws with the tool output when the exit code is non-zero' {
            { Invoke-PWSHYBKPIVTool -ExePath $script:FakeToolFail -Action 'verify-pin' -CmdletWrapping $script:Wrapping -BoundParameters @{ Pin = (ConvertTo-SecureString -String 'x' -AsPlainText -Force) } } | Should -Throw -ExpectedMessage '*tool error output*'
        }

        It 'Does not throw and returns plain strings when a successful run writes status text to stderr' {
            $result = Invoke-PWSHYBKPIVTool -ExePath $script:FakeToolStderrOk -Action 'verify-pin' -CmdletWrapping $script:Wrapping -BoundParameters @{ Pin = (ConvertTo-SecureString -String '123456' -AsPlainText -Force) }
            $result.ExitCode | Should -Be 0
            ($result.Output -join ' ') | Should -Match 'Successfully set new management key\.'
            $result.Output | ForEach-Object { $_ | Should -BeOfType ([string]) }
        }
    }

    Describe 'Write-PWSHYBKPIVPinRetryWarning' -Tag Unit {
        It 'Warns that the PIN is blocked when the failure text says so' {
            $warnings = Write-PWSHYBKPIVPinRetryWarning -Message "yubico-piv-tool.exe action 'verify-pin' failed with exit code 1. Output: Pin code blocked, use unblock-pin action to unblock." 3>&1
            $warnings | Should -Not -BeNullOrEmpty
            [string]$warnings | Should -Match 'blocked'
        }

        It 'Warns with the remaining try count when tries are low' {
            $warnings = Write-PWSHYBKPIVPinRetryWarning -Message "yubico-piv-tool.exe action 'verify-pin' failed with exit code 1. Output: Pin verification failed, 2 tries left before pin is blocked." 3>&1
            $warnings | Should -Not -BeNullOrEmpty
            [string]$warnings | Should -Match '2 tries left'
        }

        It 'Does not warn for unrelated failure text' {
            $warnings = Write-PWSHYBKPIVPinRetryWarning -Message "yubico-piv-tool.exe action 'verify-pin' failed with exit code 1. Output: Action requires a device." 3>&1
            $warnings | Should -BeNullOrEmpty
        }
    }
}