Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
a220f57
adding some basic tests and linting
byteskeptical Feb 2, 2026
8f62a04
reworking test suite, adding lint rule exception for ConvertTo-Secure…
byteskeptical Feb 2, 2026
1f051a8
adding input validation for -v, removing -v from other input validati…
byteskeptical Feb 2, 2026
ac43fce
Not sure why Pester doesn't let you pass the -Path flag with the -Con…
byteskeptical Feb 2, 2026
a0b5873
switching shells to pwsh, adding missing stubs for missing Keeper fun…
byteskeptical Feb 2, 2026
00a9db7
still trying to get workflow to reflect test result state, using Keep…
byteskeptical Feb 2, 2026
e3a9862
removing unecessary stubs and mocks for Import and Install module fun…
byteskeptical Feb 2, 2026
23bf6a9
combining Keeper tests, trying to setup local vault to remove more mo…
byteskeptical Feb 2, 2026
6fd805e
too much forcing
byteskeptical Feb 2, 2026
d7d3076
forgot to define secretName
byteskeptical Feb 3, 2026
dcbfc57
removing unsupported list entry in keeperSecret
byteskeptical Feb 3, 2026
3c002f8
ditching the nested hashtable for Files as that isn't supported by Se…
byteskeptical Feb 4, 2026
4862b5f
switch to using the BeforeAll vault password used to create the real …
byteskeptical Feb 4, 2026
9bb5739
. instead of : for :VAULT, duh
byteskeptical Feb 5, 2026
78d34b7
setting environment variable for vault password
byteskeptical Feb 7, 2026
3a3db14
using pass through on Pester call in actions workflow, letting vault …
byteskeptical Feb 17, 2026
21a013c
need to set PassThru in the config duh, just using the variable dire…
byteskeptical Feb 17, 2026
147b24f
subset of Run object PassThru is
byteskeptical Feb 17, 2026
939a6fa
trying to set the vault environment variable in the context block of …
byteskeptical Feb 17, 2026
c29bd26
need a Mock for the Files logic which passes more arguments than Secr…
byteskeptical Feb 17, 2026
70f792c
parameter name for Mock should have been Name
byteskeptical Feb 17, 2026
ab392d3
fixing Get-Secret Mock and flattening nested Files hashtable in keepe…
byteskeptical Feb 18, 2026
b677be3
adding the overwrite of VAULT environment variable back in
byteskeptical Feb 18, 2026
52a068f
somehow the module-qualified calls were causing infinite recursion lo…
byteskeptical Feb 18, 2026
461de4c
updating Get-Secret Mock once more, unify File field contents into ke…
byteskeptical Feb 20, 2026
0d73a03
Switching Files to String[] and converting from json after Set-Secret…
byteskeptical Feb 20, 2026
b6527f0
using AsPlainText as a differential switch for what to return
byteskeptical Feb 20, 2026
6492211
missed an s
byteskeptical Feb 20, 2026
b7a3df0
mis-spelled twice
byteskeptical Feb 20, 2026
d642235
just trying different things
byteskeptical Feb 25, 2026
9c58776
re-import of modules possibly clobbering the Get-Secret Mock
byteskeptical Feb 25, 2026
a15829f
fixing some typos, fix for Files syntax folly that was causing issues…
byteskeptical Mar 30, 2026
204532f
double quotes inside the nested json wrapped in single quotes, duh
byteskeptical Mar 30, 2026
b1ff371
changing strategy for keeperSecret object
byteskeptical Mar 30, 2026
04ab8df
powershell doesn't allow indexing on : variables apparently
byteskeptical Mar 30, 2026
f726d3d
need an array and don't need one to convert one of the : variables so…
byteskeptical Mar 30, 2026
c619671
idk
byteskeptical Mar 30, 2026
9715524
stringing things along
byteskeptical Mar 30, 2026
f14e1fd
No mocking of New-Item or Remove-Item in Keeper tests
byteskeptical Mar 30, 2026
ced1d4a
Another mock removal and a redirect walked into a bar
byteskeptical Mar 30, 2026
6b06b9d
more mock removal
byteskeptical Mar 31, 2026
d8f7d50
removing mock-assert, adding file verification along with temp file c…
byteskeptical Mar 31, 2026
7cf6108
try try again
byteskeptical Mar 31, 2026
b217850
scoping BeforeAll variables a bit more strictly, trying to get action…
byteskeptical Apr 5, 2026
8fcedde
module qualified Get-Secret call might be the problem
byteskeptical Apr 14, 2026
7668582
Removing Invoke-Command Mock for Keeper environment variable test, fi…
byteskeptical Apr 15, 2026
75a4729
missed a reference in the Get-Secret Mock
byteskeptical Apr 15, 2026
61810ae
trying again
byteskeptical Apr 15, 2026
7ed7e5e
parameter ordering?, still trying to get the workflow to reflect errors
byteskeptical Apr 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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@v6

- 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.Exit = $true
$config.Run.Path = '.\tests'
$config.Should.ErrorAction = 'Continue'
$config.TestResult.Enabled = $true

Invoke-Pester -Configuration $config
33 changes: 21 additions & 12 deletions rcgmsa.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,16 @@ if ($v) {

$account = (New-Object System.Management.Automation.PSCredential("$Domain\$User"))
$command = $Command -replace 'javaopts\=', 'javaopts '
$credential = $null
if ($Raw) {
$sb = $command
} else {
$sb = [scriptblock]::Create($command)
}
$keeperFiles = $null
$scriptPath = $MyInvocation.MyCommand.Path
$serverName = $env:COMPUTERNAME
$sessionOptions = New-PssessionOption -NoCompression
$sessionOptions = New-PSSessionOption -NoCompression
$sysTempDir = [System.IO.Path]::GetTempPath()
$tempDir = Join-Path -Path $sysTempDir -ChildPath ([Guid]::NewGuid().ToString())

Expand All @@ -86,8 +88,8 @@ Write-Host "********************************************************************

if ($Keeper) {
$requiredModules = @(
'Microsoft.PowerShell.SecretStore',
'Microsoft.PowerShell.SecretManagement',
'Microsoft.PowerShell.SecretStore',
'SecretManagement.Keeper'
)

Expand All @@ -107,11 +109,11 @@ if ($Keeper) {
$vault_credential = [System.Environment]::GetEnvironmentVariable("VAULT", [System.EnvironmentVariableTarget]::User)
$password = ConvertTo-SecureString -String $vault_credential -AsPlainText -Force
Unlock-SecretStore -Password $password
$credential = Get-Secret -Vault $Vault $Keeper -AsPlainText -ErrorAction Stop
$credential = Get-Secret -Vault $Vault -Name $Keeper -AsPlainText -ErrorAction Stop

if ('Files' -in $credential.Keys) {
foreach ($filename in ($credential.Files)) {
$data = Get-Secret -Vault $Vault $Keeper $credential.Files[$filename]
$data = Get-Secret -Vault $Vault -Name "$Keeper.Files[$filename]"
$filepath = Join-Path -Path $tempDir -ChildPath $filename
Set-Content -Path $filepath -Value $data -AsByteStream
}
Expand All @@ -120,8 +122,8 @@ if ($Keeper) {
}
} catch [Microsoft.PowerShell.SecretManagement.PasswordRequiredException] {
Write-Host "Unlocking Vault $Vault for one hour."
Unlock-SecretStore -Password $vault_credential
$credential = Get-Secret -Vault $Vault $Keeper -AsPlainText -ErrorAction Stop
Unlock-SecretStore -Password $password
$credential = Get-Secret -Vault $Vault -Name $Keeper -AsPlainText -ErrorAction Stop
} catch [System.Exception] {
Write-Error "Failed to retrieve Keeper secret.`nError: $($_.Exception.Message)"
$host.SetShouldExit(1)
Expand All @@ -133,30 +135,37 @@ $parameters = @{
ComputerName = $Computers
Credential = $account
ScriptBlock = {
$cred = $using:credential
$remoteSysTempDir = [System.IO.Path]::GetTempPath()
$remoteTempDir = Join-Path -Path $remoteSysTempDir -ChildPath ([Guid]::NewGuid().ToString())

New-Item -Path $remoteTempDir -ItemType Directory -Force | Out-Null

if ($credential) {
foreach ($field in ($credential.Keys)) {
if ($cred) {
foreach ($field in ($cred.Keys)) {
if ($field -ne 'Files') {
$name = "KEEPER_$($field.ToUpper())"
[Environment]::SetEnvironmentVariable(
$name,
$credential[$field],
$cred[$field],
[System.EnvironmentVariableTarget]::User
)
} else {
foreach ($filename in $keeperFiles) {
foreach ($filename in $using:keeperFiles) {
Copy-Item -Path $filename.FullName -Destination $remoteTempDir
}
}
}
}

if ($using:Orbs) {
Invoke-Command -ComputerName $using:Orbs -Credential $using:account -ScriptBlock { & $using:sb } -SessionOption $using:sessionOptions
$orbParameters = @{
ComputerName = $using:Orbs
Credential = $using:account
ScriptBlock = { & $using:sb }
SessionOption = $using:sessionOptions
}
Invoke-Command @orbParameters
} else {
Invoke-Command -ScriptBlock { & $using:sb }
}
Expand Down Expand Up @@ -188,7 +197,7 @@ try {
($spnRelatedTypes -contains $errorType) )
{
Write-Warning "First attempt failed with a possible SPN issue. Retrying with -IncludePortInSPN."
$sessionOptions = New-PssessionOption -IncludePortInSPN -NoCompression
$sessionOptions = New-PSSessionOption -IncludePortInSPN -NoCompression
$remoteResults = Invoke-Command @parameters -SessionOption $sessionOptions
Write-Host "$remoteResults"
} else {
Expand Down
169 changes: 169 additions & 0 deletions tests/rcgmsa.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
BeforeAll {
$script:credFile = [System.IO.Path]::GetTempFileName()
$script:keeperSecret = @{
API_KEY = '06ed1705-a2d5-4d16-b3b2-1a2814e7ef67'
DB_PASS = 'SuperSecretPass'
Files = '{"license.key": "FileID_123"}'
Keys = 'Files'
}
$script:scriptPath = "$PSScriptRoot/../rcgmsa.ps1"
$script:secretName = '9vb_wew-d6_AmgUNmIO6Ez'
$script:setupPath = "$PSScriptRoot/../vault.ps1"
$script:vaultName = 'devops'
$script:vaultPassword = 'VaultPassword123'

function script:Get-Credential {
[CmdletBinding()]
param(
[Parameter(Mandatory=$false)]
[string]$UserName,

[Parameter(Mandatory=$false)]
[string]$Message
)
$securePass = ConvertTo-SecureString $script:vaultPassword -AsPlainText -Force
return [PSCredential]::new($UserName, $securePass)
}

. $script:setupPath -Path $script:credFile -Vault $script:vaultName

Remove-Item Function:\script:Get-Credential -ErrorAction SilentlyContinue

[Environment]::SetEnvironmentVariable(
"VAULT",
$script:vaultPassword,
[System.EnvironmentVariableTarget]::User
)

$securePass = ConvertTo-SecureString $script:vaultPassword -AsPlainText -Force
Unlock-SecretStore -Password $securePass

Set-Secret -Name $secretName -Secret $script:keeperSecret -Vault $script:vaultName

$nvc = [System.Collections.Specialized.NameValueCollection]::new()
$json = $script:keeperSecret.Files | ConvertFrom-Json

foreach ($prop in $json.psobject.properties) {
$nvc.Add($prop.Name, $prop.Value)
}
$script:keeperSecret.Files = $nvc

$script:keeperSecret["$($script:secretName).Files[license.key]"] = `
[System.Text.Encoding]::UTF8.GetBytes('RealFileContent')
}

Describe 'Integration Tests' {

Context 'Input Validation' {
It 'Should accept valid hostnames or IPs' {
{ & $script: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!"
{ & $script: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!"
{ & $script:scriptPath -Command 'Get-Date' -Computers 'localhost' -User 'svc_account$' -Orbs 'bad_orb!' } |
Should -Throw $expectedErr
}

It 'Should output a semantic version number' {
$output = & $script: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)]$Vault,
[switch]$AsPlainText
)

if ($AsPlainText) {
return $script:keeperSecret
}

return $script:keeperSecret[$Name]
}
}

It 'Should retrieve secrets and process files when -Keeper is used' {
Mock Remove-Item -ParameterFilter { $Path -like '*\?*-?*-?*-?*-?*' } -MockWith {}

& $script:scriptPath -Command 'hostname' -Computers 'localhost' `
-User 'gmsa$' -Keeper $script:secretName -Vault 'devops'

$sysTemp = [System.IO.Path]::GetTempPath()
$foundFile = Get-ChildItem -Path $sysTemp -Filter 'license.key' -Recurse -File |
Sort-Object CreationTime -Descending |
Select-Object -First 1

$foundFile | Should -Not -BeNullOrEmpty
$foundFile.Name | Should -Be 'license.key'

$fileContent = [System.Text.Encoding]::UTF8.GetString(
[System.IO.File]::ReadAllBytes($foundFile.FullName)
)
$fileContent | Should -Be 'RealFileContent'

if ($foundFile) {
Remove-Item -Path $foundFile.DirectoryName -Recurse -Force
}
}

It 'Should inject KEEPER_ variables into the scriptblock' {
& $script:scriptPath -Command 'whoami' -Computers 'localhost' `
-User 'gmsa$' -Keeper $script:secretName 6>&1 | Out-String

[Environment]::GetEnvironmentVariable(
'KEEPER_API_KEY', [System.EnvironmentVariableTarget]::User
) | Should -Be $script:keeperSecret.API_KEY

[Environment]::GetEnvironmentVariable(
'KEEPER_DB_PASS', [System.EnvironmentVariableTarget]::User
) | Should -Be $script:keeperSecret.DB_PASS
}
}

Context 'Logic Branching' {
It 'Should execute the orbs logic when provided' {
Mock Invoke-Command { return 'Jump Host Success' }

& $script:scriptPath -Command 'whoami' -Computers 'target1' -User 'gmsa$' -Orbs 'jump1'

Should -Invoke Invoke-Command -Times 1 -Exactly
}

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

& $script:scriptPath -Command 'hostname' -Computers 'localhost' -User 'gmsa$'

Should -Invoke Invoke-Command -Times 2 -Exactly
Should -Invoke Invoke-Command -ParameterFilter {
$SessionOption.IncludePortInSPN -eq $true
} -Times 1 -Exactly
}
}
}
14 changes: 7 additions & 7 deletions vault.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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!!!"
Loading