diff --git a/.github/workflows/Action-Test.yml b/.github/workflows/Action-Test.yml index e39b913..8f4669f 100644 --- a/.github/workflows/Action-Test.yml +++ b/.github/workflows/Action-Test.yml @@ -42,3 +42,7 @@ jobs: - name: Run Install-PSModule regression tests shell: pwsh run: ./tests/Install-PSModule.Tests.ps1 + + - name: Run Import-TestData tests + shell: pwsh + run: ./tests/Import-TestData.Tests.ps1 diff --git a/src/Helpers/Helpers.psm1 b/src/Helpers/Helpers.psm1 index a4bda63..dc2bbcd 100644 --- a/src/Helpers/Helpers.psm1 +++ b/src/Helpers/Helpers.psm1 @@ -1185,3 +1185,167 @@ function Set-GitHubLogGroup { Write-Host '::endgroup::' } +function Import-TestData { + <# + .SYNOPSIS + Exposes caller-provided TestData as environment variables for later steps in the job. + + .DESCRIPTION + Reads the `PSMODULE_TEST_DATA` environment variable, which is expected to contain a single-line + JSON object with optional `secrets` and `variables` maps. Each entry is validated and written to + the file referenced by `GITHUB_ENV` so it becomes available as `$env:` in subsequent steps + of the same job. Values under `secrets` are masked in the logs via `::add-mask::`; values under + `variables` are not. When no test data is provided the function is a no-op. + + .EXAMPLE + Import-TestData + + Reads `$env:PSMODULE_TEST_DATA` and exposes its `secrets` and `variables` entries as environment + variables for the following steps in the job. + #> + [CmdletBinding()] + param() + + if ([string]::IsNullOrWhiteSpace($env:PSMODULE_TEST_DATA)) { + Write-Verbose 'No test data was provided by the calling workflow.' + return + } + if ([string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) { + throw 'Import-TestData requires the GITHUB_ENV environment file, which is only available inside GitHub Actions.' + } + try { + $data = $env:PSMODULE_TEST_DATA | ConvertFrom-Json -ErrorAction Stop + } catch { + throw "The 'TestData' secret must be valid JSON with 'secrets' and/or 'variables' maps." + } + if ($null -eq $data -or $data -isnot [pscustomobject]) { + throw "The 'TestData' secret must be a JSON object with 'secrets' and/or 'variables' maps." + } + $allowedTopLevelKeys = @('secrets', 'variables') + foreach ($propertyName in $data.PSObject.Properties.Name) { + if ($allowedTopLevelKeys -notcontains $propertyName) { + throw "The 'TestData' secret only supports 'secrets' and 'variables' maps." + } + } + $reservedNames = @('CI', 'HOME', 'PATH', 'PWD', 'SHELL', 'PSMODULE_TEST_DATA') + $reservedPrefixes = @('GITHUB_', 'RUNNER_', 'ACTIONS_') + function Assert-EnvironmentName { + <# + .SYNOPSIS + Validates that a TestData key can safely be written to GITHUB_ENV. + #> + param([string] $Name) + if ($Name -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') { + throw 'TestData keys must be valid environment variable names.' + } + $normalized = $Name.ToUpperInvariant() + if ($reservedNames -contains $normalized) { + throw 'TestData keys must not override reserved environment variables.' + } + foreach ($prefix in $reservedPrefixes) { + if ($normalized.StartsWith($prefix)) { + throw 'TestData keys must not override reserved environment variables.' + } + } + } + function Assert-Map { + <# + .SYNOPSIS + Validates that a TestData section is a JSON object map. + #> + param( + [object] $Map, + [string] $Name + ) + if ($null -eq $Map) { return } + if ($Map -isnot [pscustomobject]) { + throw "The 'TestData.$Name' value must be a JSON object." + } + } + function Get-EnvironmentValue { + <# + .SYNOPSIS + Converts a scalar TestData value to an environment variable value. + #> + param( + [object] $Value, + [string] $Name + ) + if ($null -eq $Value) { return '' } + if ( + $Value -is [pscustomobject] -or + ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) + ) { + throw "Values in 'TestData.$Name' must be scalar values." + } + return [string]$Value + } + function Add-EnvFromMap { + <# + .SYNOPSIS + Writes validated TestData entries to GITHUB_ENV. + #> + param( + [object] $Map, + [string] $Name, + [switch] $Mask + ) + Assert-Map -Map $Map -Name $Name + if ($null -eq $Map) { return } + $count = 0 + foreach ($item in $Map.PSObject.Properties) { + $entryName = $item.Name + Assert-EnvironmentName -Name $entryName + $value = Get-EnvironmentValue -Value $item.Value -Name $Name + if ($Mask) { + foreach ($line in ($value -split "`n")) { + $line = $line.TrimEnd("`r") + if ($line.Length -gt 0) { + Write-Host "::add-mask::$line" + } + } + } + do { + $delimiter = "GHENV_$([guid]::NewGuid().ToString('N'))" + } while ($value.Contains($delimiter)) + Add-Content -Path $env:GITHUB_ENV -Value "$entryName<<$delimiter" -Encoding utf8 -ErrorAction Stop + Add-Content -Path $env:GITHUB_ENV -Value $value -Encoding utf8 -ErrorAction Stop + Add-Content -Path $env:GITHUB_ENV -Value $delimiter -Encoding utf8 -ErrorAction Stop + $count++ + } + if ($count -gt 0) { + if ($Mask) { + Write-Host "Exposed $count secret value(s) as environment variables." + } else { + Write-Host "Exposed $count variable value(s) as environment variables." + } + } + } + + Assert-Map -Map $data.secrets -Name 'secrets' + Assert-Map -Map $data.variables -Name 'variables' + + $secretNames = @() + if ($null -ne $data.secrets) { + $secretNames = @($data.secrets.PSObject.Properties.Name) + } + $variableNames = @() + if ($null -ne $data.variables) { + $variableNames = @($data.variables.PSObject.Properties.Name) + } + $secretNameSet = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + foreach ($secretName in $secretNames) { + [void] $secretNameSet.Add($secretName) + } + foreach ($variableName in $variableNames) { + if ($secretNameSet.Contains($variableName)) { + throw 'TestData keys must not be duplicated across secrets and variables.' + } + } + + Add-EnvFromMap -Map $data.secrets -Name 'secrets' -Mask + Add-EnvFromMap -Map $data.variables -Name 'variables' +} + diff --git a/tests/Import-TestData.Tests.ps1 b/tests/Import-TestData.Tests.ps1 new file mode 100644 index 0000000..0e4a4a6 --- /dev/null +++ b/tests/Import-TestData.Tests.ps1 @@ -0,0 +1,127 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Path $PSScriptRoot -Parent +$helpersManifestPath = Join-Path -Path $repoRoot -ChildPath 'src/Helpers/Helpers.psd1' +$testRoot = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "Import-TestData-$([guid]::NewGuid())" + +$assert = { + param( + [bool] $Condition, + [string] $Message + ) + + if (-not $Condition) { + throw $Message + } +} + +$assertThrows = { + param( + [scriptblock] $ScriptBlock, + [string] $Pattern, + [string] $Message + ) + + $threw = $false + try { + & $ScriptBlock 6> $null + } catch { + $threw = $_.Exception.Message -like $Pattern + } + if (-not $threw) { + throw $Message + } +} + +$originalTestData = $env:PSMODULE_TEST_DATA +$originalGitHubEnv = $env:GITHUB_ENV + +try { + New-Item -Path $testRoot -ItemType Directory -Force | Out-Null + + Remove-Module -Name Helpers -Force -ErrorAction SilentlyContinue + Import-Module -Name $helpersManifestPath -Force + + $githubEnvPath = Join-Path -Path $testRoot -ChildPath 'github_env' + + # Secrets and variables are written to GITHUB_ENV, and nothing leaks to the success/pipeline stream. + Set-Content -Path $githubEnvPath -Value '' -NoNewline -Encoding utf8 + $env:GITHUB_ENV = $githubEnvPath + $env:PSMODULE_TEST_DATA = '{"secrets":{"MY_SECRET":"not-a-real-secret"},"variables":{"MY_VARIABLE":"plain-text-value"}}' + + $pipelineOutput = Import-TestData 3> $null 4> $null 5> $null 6> $null + + & $assert ($null -eq $pipelineOutput) 'Import-TestData must not write workflow commands or status messages to the success/pipeline output stream.' + + $envContent = Get-Content -Path $githubEnvPath -Raw + & $assert ($envContent -match 'MY_SECRET<<') 'Expected the secret to be written to GITHUB_ENV using heredoc syntax.' + & $assert ($envContent -like '*not-a-real-secret*') 'Expected the secret value to be written to GITHUB_ENV.' + & $assert ($envContent -match 'MY_VARIABLE<<') 'Expected the variable to be written to GITHUB_ENV using heredoc syntax.' + & $assert ($envContent -like '*plain-text-value*') 'Expected the variable value to be written to GITHUB_ENV.' + + # The mask command is emitted on the information stream (never the success stream). + Set-Content -Path $githubEnvPath -Value '' -NoNewline -Encoding utf8 + $env:PSMODULE_TEST_DATA = '{"secrets":{"MY_SECRET":"not-a-real-secret"}}' + $information = Import-TestData 6>&1 | Out-String + & $assert ($information -match '::add-mask::not-a-real-secret') 'Expected the secret to be masked via ::add-mask:: on the information stream.' + + # A no-op when no test data is provided; nothing on the success/pipeline stream. + $env:PSMODULE_TEST_DATA = '' + $noopOutput = Import-TestData 3> $null 4> $null 5> $null 6> $null + & $assert ($null -eq $noopOutput) 'The no-op path must not emit anything to the success/pipeline output stream.' + + # Representative validation failure paths. + $env:GITHUB_ENV = $githubEnvPath + + $env:PSMODULE_TEST_DATA = 'not-json' + & $assertThrows { Import-TestData } '*valid JSON*' 'Expected invalid JSON to be rejected.' + + $env:PSMODULE_TEST_DATA = '{"unexpected":{"A":"1"}}' + & $assertThrows { Import-TestData } "*only supports 'secrets' and 'variables'*" 'Expected unknown top-level keys to be rejected.' + + $env:PSMODULE_TEST_DATA = '{"variables":{"1INVALID":"x"}}' + & $assertThrows { Import-TestData } '*valid environment variable names*' 'Expected invalid env var names to be rejected.' + + $env:PSMODULE_TEST_DATA = '{"variables":{"GITHUB_TOKEN":"x"}}' + & $assertThrows { Import-TestData } '*reserved environment variables*' 'Expected reserved env var names to be rejected.' + + $env:PSMODULE_TEST_DATA = '{"secrets":{"DUP":"a"},"variables":{"DUP":"b"}}' + & $assertThrows { Import-TestData } '*duplicated across secrets and variables*' 'Expected duplicate keys to be rejected.' + + # A non-scalar value is rejected, and the error names the section ('secrets'), not the entry key. + # Regression guard for the case-insensitive $name/$Name clobber in Add-EnvFromMap. + $env:PSMODULE_TEST_DATA = '{"secrets":{"MY_SECRET":[1,2]}}' + & $assertThrows { Import-TestData } '*TestData.secrets*scalar*' 'Non-scalar value must be rejected naming the section.' + + # A failed write to GITHUB_ENV is terminating even when the caller's ErrorActionPreference is Continue. + $env:PSMODULE_TEST_DATA = '{"variables":{"MY_VARIABLE":"plain-text-value"}}' + $env:GITHUB_ENV = Join-Path -Path $testRoot -ChildPath 'missing-dir/github_env' + $previousEap = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + & $assertThrows { Import-TestData } '*missing-dir*' 'Expected an unwritable GITHUB_ENV path to raise a terminating error.' + } finally { + $ErrorActionPreference = $previousEap + } + + # Missing GITHUB_ENV fails fast with a clear message instead of a generic parameter-binding error. + $env:PSMODULE_TEST_DATA = '{"variables":{"MY_VARIABLE":"plain-text-value"}}' + $env:GITHUB_ENV = '' + $clearError = $false + try { + Import-TestData 6> $null + } catch { + $clearError = $_.Exception.Message -like '*GITHUB_ENV*' + } + & $assert $clearError 'Expected a clear error mentioning GITHUB_ENV when the environment file is not available.' +} finally { + $env:PSMODULE_TEST_DATA = $originalTestData + $env:GITHUB_ENV = $originalGitHubEnv + Remove-Module -Name Helpers -Force -ErrorAction SilentlyContinue + if (Test-Path -Path $testRoot) { + Remove-Item -Path $testRoot -Recurse -Force + } +}