diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d2009a3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,52 @@ +name: Test + +on: + push: + branches: [root] + pull_request: + branches: [root] + workflow_dispatch: + +defaults: + run: + shell: pwsh + +jobs: + test: + name: Validate + runs-on: windows-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module -Name Pester -Scope CurrentUser + Install-Module -Name PSScriptAnalyzer -Scope CurrentUser + + - name: Lint + run: | + $excludedRules = @( + 'PSAvoidUsingConvertToSecureStringWithPlainText' + ) + $results = Invoke-ScriptAnalyzer -ExcludeRule $excludedRules -Path .\rcgmsa.ps1 -Severity Error + + if ($results) { + $results | Format-Table + Write-Error 'PSScriptAnalyzer found issues. Please fix them.' -ErrorAction Stop + } else { + Write-Host 'PSScriptAnalyzer passed.' + } + + - name: Test + run: | + $config = New-PesterConfiguration + $config.Output.Verbosity = 'Detailed' + $config.Run.PassThru = $true + $config.Run.Exit = $true + $config.Run.Path = '.\tests' + $config.Should.ErrorAction = 'Continue' + $config.TestResult.Enabled = $true + + Invoke-Pester -Configuration $config diff --git a/tests/rcgmsa.Tests.ps1 b/tests/rcgmsa.Tests.ps1 new file mode 100644 index 0000000..97ba8ee --- /dev/null +++ b/tests/rcgmsa.Tests.ps1 @@ -0,0 +1,157 @@ +BeforeAll { + $keeperSecret = @{ + API_KEY = '06ed1705-a2d5-4d16-b3b2-1a2814e7ef67' + DB_PASS = 'SuperSecretPass' + Files = '["license.key"]' + Keys = 'Files' + 'license.key' = [System.Text.Encoding]::UTF8.GetBytes('RealFileContent') + } + $scriptPath = "$PSScriptRoot/../rcgmsa.ps1" + $secretName = '9vb_wew-d6_AmgUNmIO6Ez' + $setupPath = "$PSScriptRoot/../vault.ps1" + $vaultName = 'devops' + $vaultPassword = 'VaultPassword123' + + function Get-Credential { + [CmdletBinding()] + param( + [Parameter(Mandatory=$false)] + [string]$UserName, + + [Parameter(Mandatory=$false)] + [string]$Message + ) + $securePass = ConvertTo-SecureString $vaultPassword -AsPlainText -Force + return [PSCredential]::new($UserName, $securePass) + } + + $credFile = [System.IO.Path]::GetTempFileName() + . $setupPath -Path $credFile -Vault $vaultName + + Remove-Item Function:\Get-Credential -ErrorAction Stop + + [Environment]::SetEnvironmentVariable( + "VAULT", + $vaultPassword, + [System.EnvironmentVariableTarget]::User + ) + + $securePass = ConvertTo-SecureString $vaultPassword -AsPlainText -Force + Unlock-SecretStore -Password $securePass + + Set-Secret -Name $secretName -Secret $keeperSecret -Vault $vaultName + $keeperSecret.Files = @($keeperSecret.Files.Trim('[]').Split(',').Trim()) +} + +Describe 'Integration Tests' { + + Context 'Input Validation' { + It 'Should accept valid hostnames or IPs' { + { & $scriptPath -Command 'Get-Date' -Computers 'localhost','127.0.0.1' -User 'svc_account$' } | Should -Not -Throw + } + + It 'Should reject invalid characters in computer names' { + $expectedErr = "Cannot validate argument on parameter 'Computers'. Creativity meets catastrophe, invalid computer name: bad_host!" + { & $scriptPath -Command 'Get-Date' -Computers 'bad_host!' -User 'svc_account$' } | Should -Throw $expectedErr + } + + It 'Should reject invalid characters in orb names' { + $expectedErr = "Cannot validate argument on parameter 'Orbs'. For FQDN's sake, invalid computer name: bad_orb!" + { & $scriptPath -Command 'Get-Date' -Computers 'localhost' -User 'svc_account$' -Orbs 'bad_orb!' } | Should -Throw $expectedErr + } + + It 'Should output a semantic version number' { + $output = & $scriptPath -v 6>&1 + $output | Should -Match '^Version: \d+\.\d+\.\d+$' + } + } + + Context 'Keeper Vault Integration' { + BeforeAll { + Mock Get-Secret { + [CmdletBinding()] + param( + [Parameter(Position=0)]$Name, + [Parameter(Position=1)]$FieldID, + $Vault, + [switch]$AsPlainText + ) + + if ($AsPlainText) { + return $keeperSecret + } + + return $keeperSecret[$FieldID] + } + Mock Invoke-Command { return 'Remote Execution Successful' } + Mock Join-Path { param($Path, $ChildPath) return "$Path\$ChildPath" } + Mock New-Item { return 'C:\Mock\Temp' } + Mock Remove-Item {} + Mock Set-Content {} + } + + It 'Should retrieve secrets and process files when -Keeper is used' { + & $scriptPath -Command 'hostname' -Computers 'localhost' -User 'gmsa$' -Keeper $secretName -Vault 'devops' + + $api_key = [Environment]::GetEnvironmentVariable('KEEPER_API_KEY', 'User') + $api_key | Should -Be $keeperSecret.API_KEY + + [Environment]::SetEnvironmentVariable('KEEPER_API_KEY', $null, 'User') + + Assert-MockCalled Set-Content -ParameterFilter { + $Path -match 'license.key' + } -Times 1 + } + + It 'Should inject KEEPER_ variables into the scriptblock' { + Mock Invoke-Command -MockWith { + param($ScriptBlock) + return $ScriptBlock.ToString() + } + + $sbContent = & $scriptPath -Command 'echo hi' -Computers 'localhost' -User 'gmsa$' -Keeper $secretName + + $sbContent | Should -Match 'KEEPER_' + $sbContent | Should -Match '\[Environment\]::SetEnvironmentVariable' + } + } + + Context 'Logic Branching' { + BeforeAll { + Mock New-Item { return 'C:\Mock\Temp' } + Mock Remove-Item {} + Mock Set-Content {} + } + + It 'Should execute the orbs logic when provided' { + Mock Invoke-Command { return 'Jump Host Success' } + + & $scriptPath -Command 'whoami' -Computers 'target1' -User 'gmsa$' -Orbs 'jump1' + + Assert-MockCalled Invoke-Command -Times 1 + } + + It 'Should retry with -IncludePortInSPN if a specific SPN error occurs' { + Mock Invoke-Command -ParameterFilter { + -not ($SessionOption.IncludePortInSPN) + } -MockWith { + $err = [System.Management.Automation.ErrorRecord]::new( + [Exception]::new('SPN Error'), + '-2144108387,PSSessionStateBroken', + [System.Management.Automation.ErrorCategory]::OpenError, + $null + ) + throw $err + } + + Mock Invoke-Command -MockWith { return 'Retry Successful' } + + & $scriptPath -Command 'hostname' -Computers 'localhost' -User 'gmsa$' + + Assert-MockCalled Invoke-Command -Times 2 + Assert-MockCalled Invoke-Command -ParameterFilter { + $SessionOption.IncludePortInSPN -eq $true + } -Times 1 + } + } +} diff --git a/vault.ps1 b/vault.ps1 index 3570563..17b2ea3 100644 --- a/vault.ps1 +++ b/vault.ps1 @@ -40,24 +40,24 @@ $requiredModules.ForEach{ } Write-Host "Getting secure store password" -$credential = Get-Credential -UserName $vault -$credential.Password | Export-Clixml -Path $path +$credential = Get-Credential -UserName $Vault +$credential.Password | Export-Clixml -Path $Path -$password = Import-CliXml -Path $path +$password = Import-CliXml -Path $Path $parameters = @{ - Name = $vault + Name = $Vault ModuleName = $requiredModules[0] VaultParameters = @{ Confirm = $false Interaction = $null Password = $password - PasswordTimeout = $timeout + PasswordTimeout = $Timeout } DefaultVault = $true } -Write-Host "Registering [$vault] vault" +Write-Host "Registering [$Vault] vault" [Environment]::SetEnvironmentVariable("VAULT", $password, [System.EnvironmentVariableTarget]::User) Register-SecretVault @parameters -Remove-Item -Path $path -Force +Remove-Item -Path $Path -Force Write-Host "All done!!!"