Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .github/workflows/Action-Test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
164 changes: 164 additions & 0 deletions src/Helpers/Helpers.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>` 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."
}
Comment thread
MariusStorhaug marked this conversation as resolved.
}
}

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

127 changes: 127 additions & 0 deletions tests/Import-TestData.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading