diff --git a/.github/skills/video-to-gif/scripts/convert.ps1 b/.github/skills/video-to-gif/scripts/convert.ps1 index 1e51b38b..58877aab 100644 --- a/.github/skills/video-to-gif/scripts/convert.ps1 +++ b/.github/skills/video-to-gif/scripts/convert.ps1 @@ -1,3 +1,4 @@ +#!/usr/bin/env pwsh # Copyright (c) Microsoft Corporation. # SPDX-License-Identifier: MIT @@ -103,6 +104,8 @@ param( [switch]$SkipPalette ) +#region Functions + function Test-FFmpegAvailable { $ffmpegPath = Get-Command -Name 'ffmpeg' -ErrorAction SilentlyContinue if (-not $ffmpegPath) { @@ -326,113 +329,169 @@ function Invoke-TwoPassConversion { } } -# Main execution -if (-not (Test-FFmpegAvailable)) { - exit 1 -} +function Invoke-VideoConversion { + [CmdletBinding()] + [OutputType([void])] + param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$InputPath, -# Search for input file -$resolvedInput = $null -if (Test-Path -Path $InputPath -PathType Leaf) { - $resolvedInput = (Resolve-Path -Path $InputPath).Path -} -else { - $resolvedInput = Find-VideoFile -Filename $InputPath - if ($resolvedInput) { - Write-Host "Found: $resolvedInput" + [Parameter(Mandatory = $false)] + [string]$OutputPath, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 30)] + [int]$Fps = 10, + + [Parameter(Mandatory = $false)] + [ValidateRange(100, 3840)] + [int]$Width = 1280, + + [Parameter(Mandatory = $false)] + [ValidateSet('sierra2_4a', 'floyd_steinberg', 'bayer', 'none')] + [string]$Dither = 'sierra2_4a', + + [Parameter(Mandatory = $false)] + [ValidateSet('hable', 'reinhard', 'mobius', 'bt2390')] + [string]$Tonemap = 'hable', + + [Parameter(Mandatory = $false)] + [ValidateRange(0, [int]::MaxValue)] + [int]$Loop = 0, + + [Parameter(Mandatory = $false)] + [ValidateRange(0, [double]::MaxValue)] + [double]$Start, + + [Parameter(Mandatory = $false)] + [ValidateRange(0.1, [double]::MaxValue)] + [double]$Duration, + + [Parameter(Mandatory = $false)] + [switch]$SkipPalette + ) + + if (-not (Test-FFmpegAvailable)) { + throw "FFmpeg is not available" + } + + # Search for input file + $resolvedInput = $null + if (Test-Path -Path $InputPath -PathType Leaf) { + $resolvedInput = (Resolve-Path -Path $InputPath).Path } else { - $searchLocations = @( - "current directory" - "workspace root" - ) - if ($IsMacOS) { - $searchLocations += @("~/Movies", "~/Downloads", "~/Desktop") + $resolvedInput = Find-VideoFile -Filename $InputPath + if ($resolvedInput) { + Write-Host "Found: $resolvedInput" } else { - $searchLocations += @("~/Videos", "~/Downloads", "~/Desktop") + $searchLocations = @( + "current directory" + "workspace root" + ) + if ($IsMacOS) { + $searchLocations += @("~/Movies", "~/Downloads", "~/Desktop") + } + else { + $searchLocations += @("~/Videos", "~/Downloads", "~/Desktop") + } + throw "Input file not found: $InputPath`nSearched: $($searchLocations -join ', ')" } - Write-Error "Input file not found: $InputPath`nSearched: $($searchLocations -join ', ')" - exit 1 } -} -# Set default output path if not specified -if ([string]::IsNullOrEmpty($OutputPath)) { - $inputItem = Get-Item -Path $resolvedInput - $OutputPath = Join-Path -Path $inputItem.DirectoryName -ChildPath "$($inputItem.BaseName).gif" -} + # Set default output path if not specified + if ([string]::IsNullOrEmpty($OutputPath)) { + $inputItem = Get-Item -Path $resolvedInput + $OutputPath = Join-Path -Path $inputItem.DirectoryName -ChildPath "$($inputItem.BaseName).gif" + } -# Detect HDR content -$isHDR = Test-HDRContent -FilePath $resolvedInput + # Detect HDR content + $isHDR = Test-HDRContent -FilePath $resolvedInput -# Build base filter chain -$baseFilter = "fps=$Fps,scale=${Width}:-1:flags=lanczos" + # Build base filter chain + $baseFilter = "fps=$Fps,scale=${Width}:-1:flags=lanczos" -# Add HDR tonemapping if detected -# Convert HDR to SDR using selected tonemapping algorithm, then explicitly convert to sRGB for accurate GIF colors -if ($isHDR) { - $hdrFilter = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=${Tonemap}:desat=0,zscale=t=iec61966-2-1:m=bt709:r=full,format=rgb24" - $baseFilter = "$hdrFilter,$baseFilter" -} + # Add HDR tonemapping if detected + if ($isHDR) { + $hdrFilter = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=${Tonemap}:desat=0,zscale=t=iec61966-2-1:m=bt709:r=full,format=rgb24" + $baseFilter = "$hdrFilter,$baseFilter" + } -# Build time arguments -$timeArgs = @() -if ($PSBoundParameters.ContainsKey('Start')) { - $timeArgs += $Start -} -else { - $timeArgs += -1 # Sentinel value indicating no start time -} -if ($PSBoundParameters.ContainsKey('Duration')) { - $timeArgs += $Duration -} + # Build time arguments + $timeArgs = @() + if ($PSBoundParameters.ContainsKey('Start')) { + $timeArgs += $Start + } + else { + $timeArgs += -1 # Sentinel value indicating no start time + } + if ($PSBoundParameters.ContainsKey('Duration')) { + $timeArgs += $Duration + } -Write-Host "Converting: $resolvedInput" -Write-Host "Output: $OutputPath" -Write-Host "Settings: $Fps FPS, ${Width}px width, $Dither dithering, loop=$Loop" + Write-Host "Converting: $resolvedInput" + Write-Host "Output: $OutputPath" + Write-Host "Settings: $Fps FPS, ${Width}px width, $Dither dithering, loop=$Loop" -if ($PSBoundParameters.ContainsKey('Start') -or $PSBoundParameters.ContainsKey('Duration')) { - $startDisplay = if ($PSBoundParameters.ContainsKey('Start')) { "${Start}s" } else { "0s" } - $durationDisplay = if ($PSBoundParameters.ContainsKey('Duration')) { "${Duration}s" } else { "full" } - Write-Host "Time range: start=$startDisplay, duration=$durationDisplay" -} + if ($PSBoundParameters.ContainsKey('Start') -or $PSBoundParameters.ContainsKey('Duration')) { + $startDisplay = if ($PSBoundParameters.ContainsKey('Start')) { "${Start}s" } else { "0s" } + $durationDisplay = if ($PSBoundParameters.ContainsKey('Duration')) { "${Duration}s" } else { "full" } + Write-Host "Time range: start=$startDisplay, duration=$durationDisplay" + } -if ($isHDR) { - Write-Host "HDR: Detected, applying $Tonemap tonemapping" -} + if ($isHDR) { + Write-Host "HDR: Detected, applying $Tonemap tonemapping" + } -if ($SkipPalette) { - Write-Host "Mode: Single-pass (faster, lower quality)" - Write-Host "" + if ($SkipPalette) { + Write-Host "Mode: Single-pass (faster, lower quality)" + Write-Host "" - $success = Invoke-SinglePassConversion ` - -SourcePath $resolvedInput ` - -DestinationPath $OutputPath ` - -LoopCount $Loop ` - -BaseFilter $baseFilter ` - -TimeArgs $timeArgs -} -else { - Write-Host "Mode: Two-pass palette optimization" - Write-Host "" - - $success = Invoke-TwoPassConversion ` - -SourcePath $resolvedInput ` - -DestinationPath $OutputPath ` - -DitherAlgorithm $Dither ` - -LoopCount $Loop ` - -BaseFilter $baseFilter ` - -TimeArgs $timeArgs -} + $success = Invoke-SinglePassConversion ` + -SourcePath $resolvedInput ` + -DestinationPath $OutputPath ` + -LoopCount $Loop ` + -BaseFilter $baseFilter ` + -TimeArgs $timeArgs + } + else { + Write-Host "Mode: Two-pass palette optimization" + Write-Host "" + + $success = Invoke-TwoPassConversion ` + -SourcePath $resolvedInput ` + -DestinationPath $OutputPath ` + -DitherAlgorithm $Dither ` + -LoopCount $Loop ` + -BaseFilter $baseFilter ` + -TimeArgs $timeArgs + } -if ($success -and (Test-Path -Path $OutputPath)) { - $outputFile = Get-Item -Path $OutputPath - $formattedSize = Format-FileSize -Bytes $outputFile.Length - Write-Host "" - Write-Host "Conversion complete: $OutputPath ($formattedSize)" -ForegroundColor Green + if ($success -and (Test-Path -Path $OutputPath)) { + $outputFile = Get-Item -Path $OutputPath + $formattedSize = Format-FileSize -Bytes $outputFile.Length + Write-Host "" + Write-Host "Conversion complete: $OutputPath ($formattedSize)" -ForegroundColor Green + } + else { + throw "Conversion failed. Output file was not created." + } } -else { - Write-Error "Conversion failed. Output file was not created." - exit 1 + +#endregion Functions + +#region Main Execution + +if ($MyInvocation.InvocationName -ne '.') { + try { + Invoke-VideoConversion @PSBoundParameters + exit 0 + } + catch { + Write-Error -ErrorAction Continue "Video conversion failed: $($_.Exception.Message)" + exit 1 + } } + +#endregion Main Execution diff --git a/.github/workflows/pester-tests.yml b/.github/workflows/pester-tests.yml index e605a830..37209593 100644 --- a/.github/workflows/pester-tests.yml +++ b/.github/workflows/pester-tests.yml @@ -63,7 +63,9 @@ jobs: if ($changedFiles.Count -eq 0) { Write-Host "No changed PowerShell files found" - "HAS_CHANGES=false" | Out-File -FilePath $env:GITHUB_ENV -Append + if ($env:GITHUB_ENV) { + "HAS_CHANGES=false" | Out-File -FilePath $env:GITHUB_ENV -Append + } exit 0 } @@ -93,12 +95,16 @@ jobs: $testFiles = @($testFiles | Select-Object -Unique) if ($testFiles.Count -eq 0) { Write-Host "No matching test files for changed PowerShell files" - "HAS_CHANGES=false" | Out-File -FilePath $env:GITHUB_ENV -Append + if ($env:GITHUB_ENV) { + "HAS_CHANGES=false" | Out-File -FilePath $env:GITHUB_ENV -Append + } } else { Write-Host "Found $($testFiles.Count) test file(s) to run:" $testFiles | ForEach-Object { Write-Host " - $_" } - "HAS_CHANGES=true" | Out-File -FilePath $env:GITHUB_ENV -Append - "TEST_PATHS=$($testFiles -join ';')" | Out-File -FilePath $env:GITHUB_ENV -Append + if ($env:GITHUB_ENV) { + "HAS_CHANGES=true" | Out-File -FilePath $env:GITHUB_ENV -Append + "TEST_PATHS=$($testFiles -join ';')" | Out-File -FilePath $env:GITHUB_ENV -Append + } } - name: Run Pester Tests @@ -123,7 +129,7 @@ jobs: $config = & './scripts/tests/pester.config.ps1' @params $results = Invoke-Pester -Configuration $config - if ($results.FailedCount -gt 0) { + if ($results.FailedCount -gt 0 -and $env:GITHUB_ENV) { "PESTER_FAILED=true" | Out-File -FilePath $env:GITHUB_ENV -Append } @@ -131,7 +137,19 @@ jobs: if ($codeCoverage -and $results.CodeCoverage) { $coveragePercent = [math]::Round($results.CodeCoverage.CoveragePercent, 2) Write-Host "Code coverage: $coveragePercent%" - "coverage=$coveragePercent" >> $env:GITHUB_OUTPUT + if ($env:GITHUB_OUTPUT) { + $outDir = Split-Path -Parent $env:GITHUB_OUTPUT + if ($outDir -and -not (Test-Path $outDir)) { + New-Item -ItemType Directory -Path $outDir -Force | Out-Null + } + if (-not (Test-Path $env:GITHUB_OUTPUT)) { + New-Item -ItemType File -Path $env:GITHUB_OUTPUT -Force | Out-Null + } + "coverage=$coveragePercent" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + } + else { + Write-Warning "GITHUB_OUTPUT not set; skipping coverage output export." + } } continue-on-error: ${{ inputs.soft-fail }} diff --git a/scripts/README.md b/scripts/README.md index bf614ab0..42410750 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -117,11 +117,49 @@ All scripts automatically detect GitHub Actions environment and provide appropri When adding new scripts: 1. Follow PowerShell best practices (PSScriptAnalyzer compliant) -2. Support `-Verbose` and `-Debug` parameters -3. Add GitHub Actions integration using `LintingHelpers` module functions -4. Include inline help with `.SYNOPSIS`, `.DESCRIPTION`, `.PARAMETER`, and `.EXAMPLE` -5. Document in relevant README files -6. Test locally before creating PR +2. Include the entry point guard pattern (see below) +3. Support `-Verbose` and `-Debug` parameters +4. Add GitHub Actions integration using `LintingHelpers` module functions +5. Include inline help with `.SYNOPSIS`, `.DESCRIPTION`, `.PARAMETER`, and `.EXAMPLE` +6. Document in relevant README files +7. Test locally before creating PR + +### Entry Point Guard Pattern + +All production scripts use a dot-source guard that enables Pester tests to import functions without executing main logic. Extract main logic into an `Invoke-*` orchestrator function and wrap direct execution in a guard block: + +```powershell +#region Functions + +function Invoke-ScriptMain { + [CmdletBinding()] + param( <# script params #> ) + # Main logic here +} + +#endregion Functions + +#region Main Execution +if ($MyInvocation.InvocationName -ne '.') { + try { + Invoke-ScriptMain @PSBoundParameters + exit 0 + } + catch { + Write-Error -ErrorAction Continue "ScriptName failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error + exit 1 + } +} +#endregion Main Execution +``` + +Key rules: + +* The `if` guard wraps `try`/`catch` (not the reverse) +* Name the orchestrator `Invoke-*` matching the script noun +* Use `#region Functions` and `#region Main Execution` markers +* See [Generate-PrReference.ps1](dev-tools/Generate-PrReference.ps1) for a canonical example ## Related Documentation diff --git a/scripts/dev-tools/Generate-PrReference.ps1 b/scripts/dev-tools/Generate-PrReference.ps1 index f9b624db..022cd713 100644 --- a/scripts/dev-tools/Generate-PrReference.ps1 +++ b/scripts/dev-tools/Generate-PrReference.ps1 @@ -488,16 +488,15 @@ System.IO.FileInfo } #region Main Execution -try { - # Execute only when run directly, not when dot-sourced for testing - if ($MyInvocation.InvocationName -ne '.') { +if ($MyInvocation.InvocationName -ne '.') { + try { Invoke-PrReferenceGeneration -BaseBranch $BaseBranch -ExcludeMarkdownDiff:$ExcludeMarkdownDiff | Out-Null exit 0 } -} -catch { - Write-Error "Generate PR Reference failed: $($_.Exception.Message)" - Write-CIAnnotation -Message $_.Exception.Message -Level Error - exit 1 + catch { + Write-Error -ErrorAction Continue "Generate PR Reference failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error + exit 1 + } } #endregion diff --git a/scripts/extension/Package-Extension.ps1 b/scripts/extension/Package-Extension.ps1 index 4222b515..389fd150 100644 --- a/scripts/extension/Package-Extension.ps1 +++ b/scripts/extension/Package-Extension.ps1 @@ -808,9 +808,8 @@ function Invoke-PackageExtension { #endregion Orchestration Functions #region Main Execution -try { - # Only execute main logic when run directly, not when dot-sourced - if ($MyInvocation.InvocationName -ne '.') { +if ($MyInvocation.InvocationName -ne '.') { + try { $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $RepoRoot = (Get-Item "$ScriptDir/../..").FullName $ExtensionDir = Join-Path $RepoRoot "extension" @@ -824,15 +823,15 @@ try { -PreRelease:$PreRelease if (-not $result.Success) { - Write-Error $result.ErrorMessage + Write-Error -ErrorAction Continue $result.ErrorMessage exit 1 } exit 0 } + catch { + Write-Error -ErrorAction Continue "Package-Extension failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error + exit 1 + } } -catch { - Write-Error "Package Extension failed: $($_.Exception.Message)" - Write-CIAnnotation -Message $_.Exception.Message -Level Error - exit 1 -} -#endregion +#endregion Main Execution diff --git a/scripts/extension/Prepare-Extension.ps1 b/scripts/extension/Prepare-Extension.ps1 index c05be311..7d53d5ca 100644 --- a/scripts/extension/Prepare-Extension.ps1 +++ b/scripts/extension/Prepare-Extension.ps1 @@ -731,9 +731,9 @@ if ($MyInvocation.InvocationName -ne '.') { exit 0 } catch { - Write-Error "Prepare Extension failed: $($_.Exception.Message)" + Write-Error -ErrorAction Continue "Prepare-Extension failed: $($_.Exception.Message)" Write-CIAnnotation -Message $_.Exception.Message -Level Error exit 1 } } -#endregion +#endregion Main Execution diff --git a/scripts/lib/Get-VerifiedDownload.ps1 b/scripts/lib/Get-VerifiedDownload.ps1 index cfd95d2b..75b16d71 100644 --- a/scripts/lib/Get-VerifiedDownload.ps1 +++ b/scripts/lib/Get-VerifiedDownload.ps1 @@ -351,9 +351,8 @@ function Invoke-VerifiedDownload { #endregion #region Main Execution -try { - # Only execute when invoked directly (not dot-sourced) - if ($MyInvocation.InvocationName -ne '.') { +if ($MyInvocation.InvocationName -ne '.') { + try { # Require parameters for direct invocation if (-not $Url -or -not $ExpectedSHA256 -or -not $OutputPath) { Write-Error "When invoking directly, -Url, -ExpectedSHA256, and -OutputPath are required." @@ -386,10 +385,10 @@ try { $result exit 0 } + catch { + Write-Error -ErrorAction Continue "Get-VerifiedDownload failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error + exit 1 + } } -catch { - Write-Error "Get Verified Download failed: $($_.Exception.Message)" - Write-CIAnnotation -Message $_.Exception.Message -Level Error - exit 1 -} -#endregion +#endregion Main Execution diff --git a/scripts/linting/Invoke-LinkLanguageCheck.ps1 b/scripts/linting/Invoke-LinkLanguageCheck.ps1 index 995ac6e2..03539620 100644 --- a/scripts/linting/Invoke-LinkLanguageCheck.ps1 +++ b/scripts/linting/Invoke-LinkLanguageCheck.ps1 @@ -20,8 +20,6 @@ $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot "Modules/LintingHelpers.psm1") -Force Import-Module (Join-Path $PSScriptRoot "../lib/Modules/CIHelpers.psm1") -Force -$script:SkipMain = $env:HVE_SKIP_MAIN -eq '1' - function Invoke-LinkLanguageCheckCore { [CmdletBinding()] param( @@ -138,8 +136,15 @@ No URLs with language-specific paths detected. } #region Main Execution -if (-not $script:SkipMain) { - $exitCode = Invoke-LinkLanguageCheckCore -ExcludePaths $ExcludePaths - exit $exitCode +if ($MyInvocation.InvocationName -ne '.') { + try { + $exitCode = Invoke-LinkLanguageCheckCore -ExcludePaths $ExcludePaths + exit $exitCode + } + catch { + Write-Error -ErrorAction Continue "Invoke-LinkLanguageCheck failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error + exit 1 + } } -#endregion +#endregion Main Execution diff --git a/scripts/linting/Invoke-PSScriptAnalyzer.ps1 b/scripts/linting/Invoke-PSScriptAnalyzer.ps1 index 6ef9a570..43a4b690 100644 --- a/scripts/linting/Invoke-PSScriptAnalyzer.ps1 +++ b/scripts/linting/Invoke-PSScriptAnalyzer.ps1 @@ -30,41 +30,58 @@ $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot "Modules/LintingHelpers.psm1") -Force Import-Module (Join-Path $PSScriptRoot "../lib/Modules/CIHelpers.psm1") -Force -Write-Host "šŸ” Running PSScriptAnalyzer..." -ForegroundColor Cyan +#region Functions -# Ensure PSScriptAnalyzer is available -if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { - Write-Host "Installing PSScriptAnalyzer module..." -ForegroundColor Yellow - Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser -Repository PSGallery -} +function Invoke-PSScriptAnalyzerCore { + [CmdletBinding()] + [OutputType([void])] + param( + [Parameter(Mandatory = $false)] + [switch]$ChangedFilesOnly, -Import-Module PSScriptAnalyzer + [Parameter(Mandatory = $false)] + [string]$BaseBranch = "origin/main", -# Get files to analyze -$filesToAnalyze = @() + [Parameter(Mandatory = $false)] + [string]$ConfigPath = (Join-Path $PSScriptRoot "PSScriptAnalyzer.psd1"), -if ($ChangedFilesOnly) { - Write-Host "Detecting changed PowerShell files..." -ForegroundColor Cyan - $filesToAnalyze = @(Get-ChangedFilesFromGit -BaseBranch $BaseBranch -FileExtensions @('*.ps1', '*.psm1', '*.psd1')) -} -else { - Write-Host "Analyzing all PowerShell files..." -ForegroundColor Cyan - $gitignorePath = Join-Path (git rev-parse --show-toplevel 2>$null) ".gitignore" - $filesToAnalyze = @(Get-FilesRecursive -Path "." -Include @('*.ps1', '*.psm1', '*.psd1') -GitIgnorePath $gitignorePath) -} + [Parameter(Mandatory = $false)] + [string]$OutputPath = "logs/psscriptanalyzer-results.json" + ) -if (@($filesToAnalyze).Count -eq 0) { - Write-Host "āœ… No PowerShell files to analyze" -ForegroundColor Green - Set-CIOutput -Name "count" -Value "0" - Set-CIOutput -Name "issues" -Value "0" - exit 0 -} + Write-Host "šŸ” Running PSScriptAnalyzer..." -ForegroundColor Cyan -Write-Host "Analyzing $($filesToAnalyze.Count) PowerShell files..." -ForegroundColor Cyan -Set-CIOutput -Name "count" -Value $filesToAnalyze.Count + # Ensure PSScriptAnalyzer is available + if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { + Write-Host "Installing PSScriptAnalyzer module..." -ForegroundColor Yellow + Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser -Repository PSGallery + } + + Import-Module PSScriptAnalyzer + + # Get files to analyze + $filesToAnalyze = @() + + if ($ChangedFilesOnly) { + Write-Host "Detecting changed PowerShell files..." -ForegroundColor Cyan + $filesToAnalyze = @(Get-ChangedFilesFromGit -BaseBranch $BaseBranch -FileExtensions @('*.ps1', '*.psm1', '*.psd1')) + } + else { + Write-Host "Analyzing all PowerShell files..." -ForegroundColor Cyan + $gitignorePath = Join-Path (git rev-parse --show-toplevel 2>$null) ".gitignore" + $filesToAnalyze = @(Get-FilesRecursive -Path "." -Include @('*.ps1', '*.psm1', '*.psd1') -GitIgnorePath $gitignorePath) + } + + if (@($filesToAnalyze).Count -eq 0) { + Write-Host "āœ… No PowerShell files to analyze" -ForegroundColor Green + Set-CIOutput -Name "count" -Value "0" + Set-CIOutput -Name "issues" -Value "0" + return + } + + Write-Host "Analyzing $($filesToAnalyze.Count) PowerShell files..." -ForegroundColor Cyan + Set-CIOutput -Name "count" -Value $filesToAnalyze.Count -#region Main Execution -try { # Run PSScriptAnalyzer $allResults = @() $hasErrors = $false @@ -139,7 +156,7 @@ try { if ($summary.TotalIssues -eq 0) { Write-CIStepSummary -Content "āœ… **Status**: Passed`n`nAll $($summary.TotalFiles) PowerShell files passed linting checks." Write-Host "`nāœ… All PowerShell files passed PSScriptAnalyzer checks!" -ForegroundColor Green - exit 0 + return } else { Write-CIStepSummary -Content @" @@ -155,12 +172,24 @@ try { "@ Write-Host "`nāŒ PSScriptAnalyzer found $($summary.TotalIssues) issue(s)" -ForegroundColor Red - exit 1 + throw "PSScriptAnalyzer found $($summary.TotalIssues) issue(s)" } } -catch { - Write-Error -ErrorAction Continue "PSScriptAnalyzer failed: $($_.Exception.Message)" - Write-CIAnnotation -Message "PSScriptAnalyzer failed: $($_.Exception.Message)" -Level Error - exit 1 + +#endregion Functions + +#region Main Execution + +if ($MyInvocation.InvocationName -ne '.') { + try { + Invoke-PSScriptAnalyzerCore -ChangedFilesOnly:$ChangedFilesOnly -BaseBranch $BaseBranch -ConfigPath $ConfigPath -OutputPath $OutputPath + exit 0 + } + catch { + Write-Error -ErrorAction Continue "PSScriptAnalyzer failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error + exit 1 + } } -#endregion + +#endregion Main Execution diff --git a/scripts/linting/Invoke-YamlLint.ps1 b/scripts/linting/Invoke-YamlLint.ps1 index 45181e52..3fdeddc3 100644 --- a/scripts/linting/Invoke-YamlLint.ps1 +++ b/scripts/linting/Invoke-YamlLint.ps1 @@ -50,45 +50,58 @@ $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot "Modules/LintingHelpers.psm1") -Force Import-Module (Join-Path $PSScriptRoot "../lib/Modules/CIHelpers.psm1") -Force -Write-Host "šŸ” Running YAML Lint (actionlint)..." -ForegroundColor Cyan +#region Functions -# Check if actionlint is available -$actionlintPath = Get-Command actionlint -ErrorAction SilentlyContinue -if (-not $actionlintPath) { - Write-Error "actionlint is not installed. See script help for installation instructions." - exit 1 -} +function Invoke-YamlLintCore { + [CmdletBinding()] + [OutputType([void])] + param( + [Parameter(Mandatory = $false)] + [switch]$ChangedFilesOnly, -Write-Verbose "Using actionlint: $($actionlintPath.Source)" + [Parameter(Mandatory = $false)] + [string]$BaseBranch = "origin/main", -# Get files to analyze -$workflowPath = ".github/workflows" -$filesToAnalyze = @() + [Parameter(Mandatory = $false)] + [string]$OutputPath = "logs/yaml-lint-results.json" + ) -if ($ChangedFilesOnly) { - Write-Host "Detecting changed workflow files..." -ForegroundColor Cyan - $changedFiles = @(Get-ChangedFilesFromGit -BaseBranch $BaseBranch -FileExtensions @('*.yml', '*.yaml')) - $filesToAnalyze = @($changedFiles | Where-Object { $_ -like "$workflowPath/*" }) -} -else { - Write-Host "Analyzing all workflow files..." -ForegroundColor Cyan - if (Test-Path $workflowPath) { - $filesToAnalyze = @(Get-ChildItem -Path $workflowPath -File | Where-Object { $_.Extension -in '.yml', '.yaml' } | ForEach-Object { $_.FullName }) + Write-Host "šŸ” Running YAML Lint (actionlint)..." -ForegroundColor Cyan + + # Check if actionlint is available + $actionlintPath = Get-Command actionlint -ErrorAction SilentlyContinue + if (-not $actionlintPath) { + throw "actionlint is not installed. See script help for installation instructions." } -} -if (@($filesToAnalyze).Count -eq 0) { - Write-Host "āœ… No workflow files to analyze" -ForegroundColor Green - Set-CIOutput -Name "count" -Value "0" - Set-CIOutput -Name "issues" -Value "0" - exit 0 -} + Write-Verbose "Using actionlint: $($actionlintPath.Source)" -Write-Host "Analyzing $($filesToAnalyze.Count) workflow files..." -ForegroundColor Cyan -Set-CIOutput -Name "count" -Value $filesToAnalyze.Count + # Get files to analyze + $workflowPath = ".github/workflows" + $filesToAnalyze = @() + + if ($ChangedFilesOnly) { + Write-Host "Detecting changed workflow files..." -ForegroundColor Cyan + $changedFiles = @(Get-ChangedFilesFromGit -BaseBranch $BaseBranch -FileExtensions @('*.yml', '*.yaml')) + $filesToAnalyze = @($changedFiles | Where-Object { $_ -like "$workflowPath/*" }) + } + else { + Write-Host "Analyzing all workflow files..." -ForegroundColor Cyan + if (Test-Path $workflowPath) { + $filesToAnalyze = @(Get-ChildItem -Path $workflowPath -File | Where-Object { $_.Extension -in '.yml', '.yaml' } | ForEach-Object { $_.FullName }) + } + } + + if (@($filesToAnalyze).Count -eq 0) { + Write-Host "āœ… No workflow files to analyze" -ForegroundColor Green + Set-CIOutput -Name "count" -Value "0" + Set-CIOutput -Name "issues" -Value "0" + return + } + + Write-Host "Analyzing $($filesToAnalyze.Count) workflow files..." -ForegroundColor Cyan + Set-CIOutput -Name "count" -Value $filesToAnalyze.Count -#region Main Execution -try { # Run actionlint with JSON output $actionlintArgs = @('-format', '{{json .}}') if ($ChangedFilesOnly -and $filesToAnalyze.Count -gt 0) { @@ -161,7 +174,7 @@ try { if ($summary.TotalIssues -eq 0) { Write-CIStepSummary -Content "āœ… **Status**: Passed`n`nAll $($summary.TotalFiles) workflow files passed validation." Write-Host "`nāœ… All workflow files passed YAML linting!" -ForegroundColor Green - exit 0 + return } else { Write-CIStepSummary -Content @" @@ -175,12 +188,24 @@ try { "@ Write-Host "`nāŒ YAML Lint found $($summary.TotalIssues) issue(s)" -ForegroundColor Red - exit 1 + throw "YAML Lint found $($summary.TotalIssues) issue(s)" } } -catch { - Write-Error -ErrorAction Continue "YAML Lint failed: $($_.Exception.Message)" - Write-CIAnnotation -Message "YAML Lint failed: $($_.Exception.Message)" -Level Error - exit 1 + +#endregion Functions + +#region Main Execution + +if ($MyInvocation.InvocationName -ne '.') { + try { + Invoke-YamlLintCore -ChangedFilesOnly:$ChangedFilesOnly -BaseBranch $BaseBranch -OutputPath $OutputPath + exit 0 + } + catch { + Write-Error -ErrorAction Continue "YAML Lint failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error + exit 1 + } } -#endregion + +#endregion Main Execution diff --git a/scripts/linting/Link-Lang-Check.ps1 b/scripts/linting/Link-Lang-Check.ps1 index 3aa4d63b..7ac82110 100644 --- a/scripts/linting/Link-Lang-Check.ps1 +++ b/scripts/linting/Link-Lang-Check.ps1 @@ -1,3 +1,4 @@ +#!/usr/bin/env pwsh # Copyright (c) Microsoft Corporation. # SPDX-License-Identifier: MIT #Requires -Version 7.0 @@ -57,8 +58,6 @@ $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot "../lib/Modules/CIHelpers.psm1") -Force -$script:SkipMain = $env:HVE_SKIP_MAIN -eq '1' - function Get-GitTextFile { <# .SYNOPSIS @@ -281,9 +280,14 @@ function ConvertTo-JsonOutput { return $jsonData } -#region Main Execution -if (-not $script:SkipMain) { - try { +function Invoke-LinkLanguageCheck { + [CmdletBinding()] + [OutputType([void])] + param( + [switch]$Fix, + [string[]]$ExcludePaths = @() + ) + if ($Verbose) { Write-Information "Getting list of git-tracked text files..." -InformationAction Continue } @@ -373,12 +377,18 @@ if (-not $script:SkipMain) { Write-Output "No URLs containing 'en-us' were found." } } +} + +#region Main Execution +if ($MyInvocation.InvocationName -ne '.') { + try { + Invoke-LinkLanguageCheck -Fix:$Fix -ExcludePaths $ExcludePaths exit 0 } catch { - Write-Error -ErrorAction Continue "Link Lang Check failed: $($_.Exception.Message)" - Write-CIAnnotation -Message "Link Lang Check failed: $($_.Exception.Message)" -Level Error + Write-Error -ErrorAction Continue "Link-Lang-Check failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error exit 1 } } -#endregion +#endregion Main Execution diff --git a/scripts/linting/Markdown-Link-Check.ps1 b/scripts/linting/Markdown-Link-Check.ps1 index 028a1a39..4a362f02 100644 --- a/scripts/linting/Markdown-Link-Check.ps1 +++ b/scripts/linting/Markdown-Link-Check.ps1 @@ -50,8 +50,6 @@ $ErrorActionPreference = 'Stop' Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'Modules/LintingHelpers.psm1') -Force Import-Module (Join-Path -Path $PSScriptRoot -ChildPath '../lib/Modules/CIHelpers.psm1') -Force -$script:SkipMain = $env:HVE_SKIP_MAIN -eq '1' - function Get-MarkdownTarget { <# .SYNOPSIS @@ -194,9 +192,15 @@ function Get-RelativePrefix { return $normalized } -#region Main Execution -if (-not $script:SkipMain) { - try { +function Invoke-MarkdownLinkCheck { + [CmdletBinding()] + [OutputType([void])] + param( + [string[]]$Path, + [string]$ConfigPath, + [switch]$Quiet + ) + $scriptRootParent = Split-Path -Path $PSScriptRoot -Parent $repoRootPath = Split-Path -Path $scriptRootParent -Parent $repoRoot = Resolve-Path -LiteralPath $repoRootPath @@ -204,8 +208,7 @@ if (-not $script:SkipMain) { $filesToCheck = @(Get-MarkdownTarget -InputPath $Path) if (-not $filesToCheck -or @($filesToCheck).Count -eq 0) { - Write-Error 'No markdown files were found to validate.' - exit 1 + throw 'No markdown files were found to validate.' } $cli = Join-Path -Path $repoRoot.Path -ChildPath 'node_modules/.bin/markdown-link-check' @@ -214,8 +217,7 @@ if (-not $script:SkipMain) { } if (-not (Test-Path -LiteralPath $cli)) { - Write-Error 'markdown-link-check is not installed. Run "npm install --save-dev markdown-link-check" first.' - exit 1 + throw 'markdown-link-check is not installed. Run "npm install --save-dev markdown-link-check" first.' } $baseArguments = @('-c', $config.Path) @@ -370,8 +372,7 @@ For more information, see the [markdown-link-check documentation](https://github Write-CIStepSummary -Content $summaryContent Set-CIEnv -Name "MARKDOWN_LINK_CHECK_FAILED" -Value "true" - Write-Error ("markdown-link-check reported failures for: {0}" -f ($failedFiles -join ', ')) - exit 1 + throw ("markdown-link-check reported failures for: {0}" -f ($failedFiles -join ', ')) } else { $summaryContent = @" @@ -386,13 +387,19 @@ Great job! All markdown links are valid. šŸŽ‰ Write-CIStepSummary -Content $summaryContent Write-Output 'markdown-link-check completed successfully.' - exit 0 } +} + +#region Main Execution +if ($MyInvocation.InvocationName -ne '.') { + try { + Invoke-MarkdownLinkCheck -Path $Path -ConfigPath $ConfigPath -Quiet:$Quiet + exit 0 } catch { - Write-Error "Markdown Link Check failed: $($_.Exception.Message)" -ErrorAction Continue - Write-CIAnnotation -Message "Markdown Link Check failed: $($_.Exception.Message)" -Level Error + Write-Error -ErrorAction Continue "Markdown-Link-Check failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error exit 1 } } -#endregion +#endregion Main Execution diff --git a/scripts/linting/README.md b/scripts/linting/README.md index bb749d87..70c1aea9 100644 --- a/scripts/linting/README.md +++ b/scripts/linting/README.md @@ -499,45 +499,67 @@ param( [switch]$ChangedFilesOnly ) -# Import shared helpers -$scriptPath = $PSScriptRoot -Import-Module "$scriptPath/Modules/LintingHelpers.psm1" -Force -Import-Module "$scriptPath/../lib/Modules/CIHelpers.psm1" -Force - -# Main validation logic -Write-Host "šŸ” Running MyValidator..." - -if ($ChangedFilesOnly) { - $files = Get-ChangedFilesFromGit -FileExtension '.ext' -} else { - $files = Get-FilesRecursive -Path (Get-Location) -Pattern '*.ext' -} +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'Modules/LintingHelpers.psm1') -Force +Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force -if ($files.Count -eq 0) { - Write-Host "āœ… No files to validate" - exit 0 -} +#region Functions + +function Invoke-MyValidatorCore { + [CmdletBinding()] + [OutputType([void])] + param( + [switch]$ChangedFilesOnly + ) + + Write-Host "šŸ” Running MyValidator..." -# Perform validation -$issues = @() -foreach ($file in $files) { - # Validation logic here - if ($issue) { - $issues += $issue - Write-CIAnnotation -Level 'Error' -Message 'Issue found' -File $file + if ($ChangedFilesOnly) { + $files = Get-ChangedFilesFromGit -FileExtension '.ext' + } + else { + $files = Get-FilesRecursive -Path (Get-Location) -Pattern '*.ext' + } + + if ($files.Count -eq 0) { + Write-Host "āœ… No files to validate" + return } -} -# Export results -Write-CIStepSummary -Content "## Validation Results`n`nFound $($issues.Count) issues" + # Perform validation + $issues = @() + foreach ($file in $files) { + # Validation logic here + if ($issue) { + $issues += $issue + Write-CIAnnotation -Level 'Error' -Message 'Issue found' -File $file + } + } + + Write-CIStepSummary -Content "## Validation Results`n`nFound $($issues.Count) issues" + + if ($issues.Count -gt 0) { + throw "Found $($issues.Count) issues" + } -if ($issues.Count -gt 0) { - Write-Host "āŒ Found $($issues.Count) issues" - exit 1 + Write-Host "āœ… All files validated successfully" } -Write-Host "āœ… All files validated successfully" -exit 0 +#endregion Functions + +#region Main Execution +if ($MyInvocation.InvocationName -ne '.') { + try { + Invoke-MyValidatorCore @PSBoundParameters + exit 0 + } + catch { + Write-Error -ErrorAction Continue "Invoke-MyValidator failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error + exit 1 + } +} +#endregion Main Execution ``` ## Contributing diff --git a/scripts/linting/Test-CopyrightHeaders.ps1 b/scripts/linting/Test-CopyrightHeaders.ps1 index 7e71406b..a73f27b3 100644 --- a/scripts/linting/Test-CopyrightHeaders.ps1 +++ b/scripts/linting/Test-CopyrightHeaders.ps1 @@ -74,8 +74,7 @@ $helpersPath = Join-Path $PSScriptRoot "Modules/LintingHelpers.psm1" if (Test-Path $helpersPath) { Import-Module $helpersPath -Force } - -Write-Host "šŸ“„ Validating copyright headers..." -ForegroundColor Cyan +Import-Module (Join-Path $PSScriptRoot "../lib/Modules/CIHelpers.psm1") -Force # Header patterns to check $CopyrightPattern = '^\s*#\s*Copyright\s*\(c\)\s*Microsoft\s+Corporation\.?\s*$' @@ -84,6 +83,8 @@ $SpdxPattern = '^\s*#\s*SPDX-License-Identifier:\s*MIT\s*$' # Lines to check (accounting for shebang, #Requires, etc.) $MaxLinesToCheck = 15 +#region Functions + function Test-FileHeaders { [CmdletBinding()] [OutputType([hashtable])] @@ -160,74 +161,113 @@ function Get-FilesToCheck { return $files | Sort-Object FullName -Unique } -# Ensure output directory exists -$outputDir = Split-Path -Parent $OutputPath -if ($outputDir -and -not (Test-Path $outputDir)) { - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null -} +function Invoke-CopyrightHeaderCheck { + [CmdletBinding()] + [OutputType([void])] + param( + [Parameter(Mandatory = $false)] + [string]$Path = $(if ($p = git rev-parse --show-toplevel 2>$null) { $p } else { '.' }), -# Get files to check -Write-Host "Scanning for source files in: $Path" -ForegroundColor Gray -$filesToCheck = Get-FilesToCheck -RootPath $Path -Extensions $FileExtensions -Exclude $ExcludePaths + [Parameter(Mandatory = $false)] + [string[]]$FileExtensions = @('*.ps1', '*.psm1', '*.psd1', '*.sh'), -if ($filesToCheck.Count -eq 0) { - Write-Host "āš ļø No files found matching criteria" -ForegroundColor Yellow - exit 0 -} + [Parameter(Mandatory = $false)] + [string]$OutputPath = "logs/copyright-header-results.json", -Write-Host "Found $($filesToCheck.Count) files to check" -ForegroundColor Gray + [Parameter(Mandatory = $false)] + [switch]$FailOnMissing, -# Check each file -$results = @() -$filesWithHeaders = 0 -$filesMissingHeaders = 0 + [Parameter(Mandatory = $false)] + [string[]]$ExcludePaths = @('node_modules', '.git', 'vendor', 'logs') + ) -foreach ($file in $filesToCheck) { - $fileResult = Test-FileHeaders -FilePath $file.FullName + Write-Host "šŸ“„ Validating copyright headers..." -ForegroundColor Cyan - if ($fileResult.valid) { - $filesWithHeaders++ - Write-Host " āœ… $($fileResult.file)" -ForegroundColor Green + # Ensure output directory exists + $outputDir = Split-Path -Parent $OutputPath + if ($outputDir -and -not (Test-Path $outputDir)) { + New-Item -ItemType Directory -Path $outputDir -Force | Out-Null } - else { - $filesMissingHeaders++ - $missing = @() - if (-not $fileResult.hasCopyright) { $missing += "copyright" } - if (-not $fileResult.hasSpdx) { $missing += "SPDX" } - Write-Host " āŒ $($fileResult.file) (missing: $($missing -join ', '))" -ForegroundColor Red + + # Get files to check + Write-Host "Scanning for source files in: $Path" -ForegroundColor Gray + $filesToCheck = Get-FilesToCheck -RootPath $Path -Extensions $FileExtensions -Exclude $ExcludePaths + + if ($filesToCheck.Count -eq 0) { + Write-Host "āš ļø No files found matching criteria" -ForegroundColor Yellow + return } - $results += $fileResult -} + Write-Host "Found $($filesToCheck.Count) files to check" -ForegroundColor Gray + + # Check each file + $results = @() + $filesWithHeaders = 0 + $filesMissingHeaders = 0 + + foreach ($file in $filesToCheck) { + $fileResult = Test-FileHeaders -FilePath $file.FullName + + if ($fileResult.valid) { + $filesWithHeaders++ + Write-Host " āœ… $($fileResult.file)" -ForegroundColor Green + } + else { + $filesMissingHeaders++ + $missing = @() + if (-not $fileResult.hasCopyright) { $missing += "copyright" } + if (-not $fileResult.hasSpdx) { $missing += "SPDX" } + Write-Host " āŒ $($fileResult.file) (missing: $($missing -join ', '))" -ForegroundColor Red + } -# Build output object -$output = @{ - timestamp = (Get-Date -Format "o") - totalFiles = $filesToCheck.Count - filesWithHeaders = $filesWithHeaders - filesMissingHeaders = $filesMissingHeaders - compliancePercentage = if ($filesToCheck.Count -gt 0) { - [math]::Round(($filesWithHeaders / $filesToCheck.Count) * 100, 2) - } else { 100 } - results = $results + $results += $fileResult + } + + # Build output object + $output = @{ + timestamp = (Get-Date -Format "o") + totalFiles = $filesToCheck.Count + filesWithHeaders = $filesWithHeaders + filesMissingHeaders = $filesMissingHeaders + compliancePercentage = if ($filesToCheck.Count -gt 0) { + [math]::Round(($filesWithHeaders / $filesToCheck.Count) * 100, 2) + } else { 100 } + results = $results + } + + # Write results to file + $output | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputPath -Encoding UTF8 + Write-Host "`nšŸ“Š Results written to: $OutputPath" -ForegroundColor Cyan + + # Summary + Write-Host "`nšŸ“‹ Summary:" -ForegroundColor Cyan + Write-Host " Total files: $($output.totalFiles)" -ForegroundColor Gray + Write-Host " With headers: $($output.filesWithHeaders)" -ForegroundColor Green + Write-Host " Missing headers: $($output.filesMissingHeaders)" -ForegroundColor $(if ($output.filesMissingHeaders -gt 0) { 'Red' } else { 'Green' }) + Write-Host " Compliance: $($output.compliancePercentage)%" -ForegroundColor $(if ($output.compliancePercentage -eq 100) { 'Green' } else { 'Yellow' }) + + # Throw if requested and files are missing headers + if ($FailOnMissing -and $filesMissingHeaders -gt 0) { + throw "Validation failed: $filesMissingHeaders file(s) missing required headers" + } + + Write-Host "`nāœ… Copyright header validation complete" -ForegroundColor Green } -# Write results to file -$output | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputPath -Encoding UTF8 -Write-Host "`nšŸ“Š Results written to: $OutputPath" -ForegroundColor Cyan - -# Summary -Write-Host "`nšŸ“‹ Summary:" -ForegroundColor Cyan -Write-Host " Total files: $($output.totalFiles)" -ForegroundColor Gray -Write-Host " With headers: $($output.filesWithHeaders)" -ForegroundColor Green -Write-Host " Missing headers: $($output.filesMissingHeaders)" -ForegroundColor $(if ($output.filesMissingHeaders -gt 0) { 'Red' } else { 'Green' }) -Write-Host " Compliance: $($output.compliancePercentage)%" -ForegroundColor $(if ($output.compliancePercentage -eq 100) { 'Green' } else { 'Yellow' }) - -# Exit with error if requested and files are missing headers -if ($FailOnMissing -and $filesMissingHeaders -gt 0) { - Write-Host "`nāŒ Validation failed: $filesMissingHeaders file(s) missing required headers" -ForegroundColor Red - exit 1 +#endregion Functions + +#region Main Execution + +if ($MyInvocation.InvocationName -ne '.') { + try { + Invoke-CopyrightHeaderCheck -Path $Path -FileExtensions $FileExtensions -OutputPath $OutputPath -FailOnMissing:$FailOnMissing -ExcludePaths $ExcludePaths + exit 0 + } + catch { + Write-Error -ErrorAction Continue "Copyright header validation failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error + exit 1 + } } -Write-Host "`nāœ… Copyright header validation complete" -ForegroundColor Green -exit 0 +#endregion Main Execution diff --git a/scripts/linting/Validate-MarkdownFrontmatter.ps1 b/scripts/linting/Validate-MarkdownFrontmatter.ps1 index 9d285ce9..de815830 100644 --- a/scripts/linting/Validate-MarkdownFrontmatter.ps1 +++ b/scripts/linting/Validate-MarkdownFrontmatter.ps1 @@ -19,6 +19,7 @@ using namespace System.Collections.Generic # (FileTypeInfo, ValidationIssue, etc.) available at parse time for [OutputType] attributes using module .\Modules\FrontmatterValidation.psm1 +[CmdletBinding()] param( [Parameter(Mandatory = $false)] [string[]]$Paths = @('.'), @@ -745,8 +746,8 @@ function Get-ChangedMarkdownFileGroup { } #region Main Execution -try { - if ($MyInvocation.InvocationName -ne '.') { +if ($MyInvocation.InvocationName -ne '.') { + try { if ($ChangedFilesOnly) { $result = Test-FrontmatterValidation -ChangedFilesOnly -BaseBranch $BaseBranch -ExcludePaths $ExcludePaths -WarningsAsErrors:$WarningsAsErrors -EnableSchemaValidation:$EnableSchemaValidation -FooterExcludePaths $FooterExcludePaths -SkipFooterValidation:$SkipFooterValidation } @@ -758,7 +759,6 @@ try { } # Normalize result: if pipeline output produced an array, extract the ValidationSummary object - # PowerShell functions can inadvertently output multiple objects; take the last (the return value) if ($result -is [System.Array]) { $result = $result | Where-Object { $null -ne $_ -and $_.GetType().GetMethod('GetExitCode') } | Select-Object -Last 1 } @@ -769,8 +769,7 @@ try { exit 0 } - # Validate result object before calling GetExitCode to prevent method invocation errors - # PowerShell class methods are compiled to .NET type metadata, not stored in PSObject.Methods (ETS only) + # Validate result object before calling GetExitCode if ($null -eq $result -or $null -eq $result.GetType().GetMethod('GetExitCode')) { $resultTypeName = if ($null -eq $result) { '' } else { $result.GetType().FullName } Write-Host "Validation did not produce a usable result object (type: $resultTypeName). Exiting with code 1." @@ -786,10 +785,10 @@ try { exit 0 } } + catch { + Write-Error -ErrorAction Continue "Validate-MarkdownFrontmatter failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error + exit 1 + } } -catch { - Write-Error -ErrorAction Continue "Validate Markdown Frontmatter failed: $($_.Exception.Message)" - Write-CIAnnotation -Message "Validate Markdown Frontmatter failed: $($_.Exception.Message)" -Level Error - exit 1 -} -#endregion +#endregion Main Execution diff --git a/scripts/security/Test-ActionVersionConsistency.ps1 b/scripts/security/Test-ActionVersionConsistency.ps1 index e059a968..bc577b99 100644 --- a/scripts/security/Test-ActionVersionConsistency.ps1 +++ b/scripts/security/Test-ActionVersionConsistency.ps1 @@ -75,9 +75,6 @@ $ErrorActionPreference = 'Stop' # Import CIHelpers for workflow command escaping Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force -# Support dot-sourcing for Pester tests -$script:SkipMain = $env:HVE_SKIP_MAIN -eq '1' - function Write-ConsistencyLog { param( [Parameter(Mandatory = $true)] @@ -357,52 +354,73 @@ function Export-ConsistencyReport { #region Main Execution -try { - if (-not $script:SkipMain) { - Write-ConsistencyLog 'Starting GitHub Actions version consistency analysis...' -Level Info - Write-ConsistencyLog "Scanning path: $Path" -Level Info +function Invoke-ActionVersionConsistencyCheck { + [CmdletBinding()] + [OutputType([void])] + param( + [Parameter(Mandatory = $false)] + [string]$Path = '.github/workflows', - # Scan for violations - $result = Get-ActionVersionViolations -WorkflowPath $Path + [Parameter(Mandatory = $false)] + [ValidateSet('Table', 'Json', 'Sarif')] + [string]$Format = 'Table', - $violations = $result.Violations - $mismatchCount = @($violations | Where-Object { $_.ViolationType -eq 'VersionMismatch' }).Count - $missingCount = @($violations | Where-Object { $_.ViolationType -eq 'MissingVersionComment' }).Count + [Parameter(Mandatory = $false)] + [string]$OutputPath, - Write-ConsistencyLog "Scanned $($result.TotalActions) SHA-pinned actions" -Level Info - Write-ConsistencyLog "Found $mismatchCount version mismatches" -Level $(if ($mismatchCount -gt 0) { 'Warning' } else { 'Info' }) - Write-ConsistencyLog "Found $missingCount missing version comments" -Level $(if ($missingCount -gt 0) { 'Warning' } else { 'Info' }) + [Parameter(Mandatory = $false)] + [switch]$FailOnMismatch, - # Export report - Export-ConsistencyReport -Violations $violations -Format $Format -OutputPath $OutputPath -TotalActions $result.TotalActions + [Parameter(Mandatory = $false)] + [switch]$FailOnMissingComment + ) - # Determine exit code - $exitCode = 0 + Write-ConsistencyLog 'Starting GitHub Actions version consistency analysis...' -Level Info + Write-ConsistencyLog "Scanning path: $Path" -Level Info - if ($FailOnMismatch -and $mismatchCount -gt 0) { - Write-ConsistencyLog "Failing due to $mismatchCount version mismatch(es) (-FailOnMismatch enabled)" -Level Error - $exitCode = 1 - } + $result = Get-ActionVersionViolations -WorkflowPath $Path - if ($FailOnMissingComment -and $missingCount -gt 0) { - Write-ConsistencyLog "Failing due to $missingCount missing version comment(s) (-FailOnMissingComment enabled)" -Level Error - $exitCode = 1 - } + $violations = $result.Violations + $mismatchCount = @($violations | Where-Object { $_.ViolationType -eq 'VersionMismatch' }).Count + $missingCount = @($violations | Where-Object { $_.ViolationType -eq 'MissingVersionComment' }).Count - if ($exitCode -eq 0 -and $violations.Count -eq 0) { - Write-ConsistencyLog 'All SHA-pinned actions have consistent version comments!' -Level Success - } + Write-ConsistencyLog "Scanned $($result.TotalActions) SHA-pinned actions" -Level Info + Write-ConsistencyLog "Found $mismatchCount version mismatches" -Level $(if ($mismatchCount -gt 0) { 'Warning' } else { 'Info' }) + Write-ConsistencyLog "Found $missingCount missing version comments" -Level $(if ($missingCount -gt 0) { 'Warning' } else { 'Info' }) + + Export-ConsistencyReport -Violations $violations -Format $Format -OutputPath $OutputPath -TotalActions $result.TotalActions + + $failed = $false - exit $exitCode + if ($FailOnMismatch -and $mismatchCount -gt 0) { + Write-ConsistencyLog "Failing due to $mismatchCount version mismatch(es) (-FailOnMismatch enabled)" -Level Error + $failed = $true + } + + if ($FailOnMissingComment -and $missingCount -gt 0) { + Write-ConsistencyLog "Failing due to $missingCount missing version comment(s) (-FailOnMissingComment enabled)" -Level Error + $failed = $true + } + + if ($failed) { + throw 'Version consistency violations detected' + } + + if ($violations.Count -eq 0) { + Write-ConsistencyLog 'All SHA-pinned actions have consistent version comments!' -Level Success } } -catch { - Write-ConsistencyLog "Version consistency analysis failed: $($_.Exception.Message)" -Level Error - if ($env:GITHUB_ACTIONS -eq 'true') { - $escapedMsg = ConvertTo-GitHubActionsEscaped -Value $_.Exception.Message - Write-Output "::error::$escapedMsg" + +if ($MyInvocation.InvocationName -ne '.') { + try { + Invoke-ActionVersionConsistencyCheck -Path $Path -Format $Format -OutputPath $OutputPath -FailOnMismatch:$FailOnMismatch -FailOnMissingComment:$FailOnMissingComment + exit 0 + } + catch { + Write-Error -ErrorAction Continue "Test-ActionVersionConsistency failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error + exit 1 } - exit 1 } -#endregion +#endregion Main Execution diff --git a/scripts/security/Test-DependencyPinning.ps1 b/scripts/security/Test-DependencyPinning.ps1 index 4eef185a..db3064f3 100644 --- a/scripts/security/Test-DependencyPinning.ps1 +++ b/scripts/security/Test-DependencyPinning.ps1 @@ -168,6 +168,8 @@ $DependencyPatterns = @{ # DependencyViolation and ComplianceReport classes moved to ./Modules/SecurityClasses.psm1 +#region Functions + function Test-ShellDownloadSecurity { <# .SYNOPSIS @@ -306,6 +308,7 @@ function Get-NpmDependencyViolations { } function Write-PinningLog { + [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Message, @@ -324,6 +327,7 @@ function Get-FilesToScan { .SYNOPSIS Discovers files to scan based on dependency type patterns. #> + [CmdletBinding()] param( [string]$ScanPath, [string[]]$Types, @@ -379,6 +383,7 @@ function Test-SHAPinning { .SYNOPSIS Tests if a version reference is properly SHA-pinned. #> + [CmdletBinding()] param( [string]$Version, [string]$Type @@ -397,6 +402,7 @@ function Get-DependencyViolation { .SYNOPSIS Scans a file for dependency pinning violations. #> + [CmdletBinding()] param( [hashtable]$FileInfo ) @@ -501,6 +507,7 @@ function Get-RemediationSuggestion { .SYNOPSIS Generates remediation suggestions for unpinned dependencies. #> + [CmdletBinding()] param( [DependencyViolation]$Violation, @@ -551,6 +558,7 @@ function Get-ComplianceReportData { .SYNOPSIS Generates a comprehensive compliance report. #> + [CmdletBinding()] param( [DependencyViolation[]]$Violations, [hashtable[]]$ScannedFiles, @@ -609,6 +617,7 @@ function Export-ComplianceReport { .SYNOPSIS Exports compliance report in specified format. #> + [CmdletBinding()] param( # Use duck typing to avoid class type collision during code coverage instrumentation $Report, @@ -742,6 +751,7 @@ function Export-CICDArtifact { .SYNOPSIS Exports compliance report as CI/CD artifacts for both GitHub Actions and Azure DevOps. #> + [CmdletBinding()] param( [ComplianceReport]$Report, [string]$ReportPath @@ -784,91 +794,134 @@ $(if ($Report.UnpinnedDependencies -gt 0) { "āš ļø **Action Required:** $($Repo Write-PinningLog "Compliance artifacts prepared for CI/CD consumption" -Level Success } -#region Main Execution +function Invoke-DependencyPinningAnalysis { + <# + .SYNOPSIS + Orchestrates dependency pinning compliance analysis. + #> + [CmdletBinding()] + [OutputType([void])] + param( + [Parameter()] + [string]$Path = ".", -# Only execute when invoked directly (not dot-sourced) -try { - if ($MyInvocation.InvocationName -ne '.') { - Write-PinningLog "Starting dependency pinning compliance analysis..." -Level Info - Write-PinningLog "PowerShell Version: $($PSVersionTable.PSVersion)" -Level Info - Write-PinningLog "Platform: $($PSVersionTable.Platform)" -Level Info - - # Parse include types and exclude paths - $typesToCheck = $IncludeTypes.Split(',') | ForEach-Object { $_.Trim() } - $excludePatterns = if ($ExcludePaths) { $ExcludePaths.Split(',') | ForEach-Object { $_.Trim() } } else { @() } - - Write-PinningLog "Scanning path: $Path" -Level Info - Write-PinningLog "Include types: $($typesToCheck -join ', ')" -Level Info - if ($excludePatterns) { Write-PinningLog "Exclude patterns: $($excludePatterns -join ', ')" -Level Info } - - # Discover files to scan - $filesToScan = @(Get-FilesToScan -ScanPath $Path -Types $typesToCheck -ExcludePatterns $excludePatterns -Recursive:$Recursive) - Write-PinningLog "Found $(@($filesToScan).Count) files to scan" -Level Info - - # Scan for violations - $allViolations = @() - foreach ($fileInfo in $filesToScan) { - Write-PinningLog "Scanning: $($fileInfo.RelativePath)" -Level Info - $violations = @(Get-DependencyViolation -FileInfo $fileInfo) - - # Add remediation suggestions - foreach ($violation in $violations) { - $violation.Remediation = Get-RemediationSuggestion -Violation $violation -Remediate:$Remediate - } + [Parameter()] + [switch]$Recursive, + + [Parameter()] + [string]$IncludeTypes = "github-actions,npm,pip,shell-downloads", + + [Parameter()] + [string]$ExcludePaths = "", + + [Parameter()] + [string]$Format = 'json', + + [Parameter()] + [string]$OutputPath = 'logs/dependency-pinning-results.json', + + [Parameter()] + [switch]$FailOnUnpinned, + + [Parameter()] + [int]$Threshold = 95, - $allViolations += $violations + [Parameter()] + [switch]$Remediate + ) + + Write-PinningLog "Starting dependency pinning compliance analysis..." -Level Info + Write-PinningLog "PowerShell Version: $($PSVersionTable.PSVersion)" -Level Info + Write-PinningLog "Platform: $($PSVersionTable.Platform)" -Level Info + + # Parse include types and exclude paths + $typesToCheck = $IncludeTypes.Split(',') | ForEach-Object { $_.Trim() } + $excludePatterns = if ($ExcludePaths) { $ExcludePaths.Split(',') | ForEach-Object { $_.Trim() } } else { @() } + + Write-PinningLog "Scanning path: $Path" -Level Info + Write-PinningLog "Include types: $($typesToCheck -join ', ')" -Level Info + if ($excludePatterns) { Write-PinningLog "Exclude patterns: $($excludePatterns -join ', ')" -Level Info } + + # Discover files to scan + $filesToScan = @(Get-FilesToScan -ScanPath $Path -Types $typesToCheck -ExcludePatterns $excludePatterns -Recursive:$Recursive) + Write-PinningLog "Found $(@($filesToScan).Count) files to scan" -Level Info + + # Scan for violations + $allViolations = @() + foreach ($fileInfo in $filesToScan) { + Write-PinningLog "Scanning: $($fileInfo.RelativePath)" -Level Info + $violations = @(Get-DependencyViolation -FileInfo $fileInfo) + + # Add remediation suggestions + foreach ($violation in $violations) { + $violation.Remediation = Get-RemediationSuggestion -Violation $violation -Remediate:$Remediate } - Write-PinningLog "Found $(@($allViolations).Count) dependency pinning violations" -Level Info + $allViolations += $violations + } - # Generate compliance report - $report = Get-ComplianceReportData -Violations $allViolations -ScannedFiles $filesToScan -ScanPath $Path -Remediate:$Remediate + Write-PinningLog "Found $(@($allViolations).Count) dependency pinning violations" -Level Info - # Export report - Export-ComplianceReport -Report $report -Format $Format -OutputPath $OutputPath + # Generate compliance report + $report = Get-ComplianceReportData -Violations $allViolations -ScannedFiles $filesToScan -ScanPath $Path -Remediate:$Remediate - # Export CI/CD artifacts - Export-CICDArtifact -Report $report -ReportPath $OutputPath + # Export report + Export-ComplianceReport -Report $report -Format $Format -OutputPath $OutputPath - # Display summary - Write-PinningLog "Compliance Analysis Complete!" -Level Success - Write-PinningLog "Compliance Score: $($report.ComplianceScore)%" -Level Info - Write-PinningLog "Total Dependencies: $($report.TotalDependencies)" -Level Info - Write-PinningLog "Unpinned Dependencies: $($report.UnpinnedDependencies)" -Level Info + # Export CI/CD artifacts + Export-CICDArtifact -Report $report -ReportPath $OutputPath - if ($report.UnpinnedDependencies -gt 0) { - Write-PinningLog "$($report.UnpinnedDependencies) dependencies require SHA pinning for security compliance" -Level Warning + # Display summary + Write-PinningLog "Compliance Analysis Complete!" -Level Success + Write-PinningLog "Compliance Score: $($report.ComplianceScore)%" -Level Info + Write-PinningLog "Total Dependencies: $($report.TotalDependencies)" -Level Info + Write-PinningLog "Unpinned Dependencies: $($report.UnpinnedDependencies)" -Level Info - # Check threshold compliance - if ($report.ComplianceScore -lt $Threshold) { - Write-PinningLog "Compliance score $($report.ComplianceScore)% is below threshold $Threshold%" -Level Error + if ($report.UnpinnedDependencies -gt 0) { + Write-PinningLog "$($report.UnpinnedDependencies) dependencies require SHA pinning for security compliance" -Level Warning - if ($FailOnUnpinned) { - Write-PinningLog "Failing build due to compliance threshold violation (-FailOnUnpinned enabled)" -Level Error - exit 1 - } - else { - Write-PinningLog "Threshold violation detected but continuing (soft-fail mode)" -Level Warning - } + # Check threshold compliance + if ($report.ComplianceScore -lt $Threshold) { + Write-PinningLog "Compliance score $($report.ComplianceScore)% is below threshold $Threshold%" -Level Error + + if ($FailOnUnpinned) { + Write-PinningLog "Failing build due to compliance threshold violation (-FailOnUnpinned enabled)" -Level Error + throw "Compliance score $($report.ComplianceScore)% is below threshold $Threshold% (-FailOnUnpinned enabled)" } else { - Write-PinningLog "Compliance score $($report.ComplianceScore)% meets threshold $Threshold%" -Level Info + Write-PinningLog "Threshold violation detected but continuing (soft-fail mode)" -Level Warning } } else { - Write-PinningLog "All dependencies are properly pinned! āœ… (100% compliance, exceeds $Threshold% threshold)" -Level Success - exit 0 + Write-PinningLog "Compliance score $($report.ComplianceScore)% meets threshold $Threshold%" -Level Info } } else { - Write-Error "Test Dependency Pinning failed: will not execute if dot-sourced" - exit 1 + Write-PinningLog "All dependencies are properly pinned! āœ… (100% compliance, exceeds $Threshold% threshold)" -Level Success } } -catch { - Write-PinningLog "Dependency pinning analysis failed: $($_.Exception.Message)" -Level Error - Write-CIAnnotation -Message $_.Exception.Message -Level Error - exit 1 -} -#endregion +#endregion Functions + +#region Main Execution +if ($MyInvocation.InvocationName -ne '.') { + try { + Invoke-DependencyPinningAnalysis ` + -Path $Path ` + -Recursive:$Recursive ` + -IncludeTypes $IncludeTypes ` + -ExcludePaths $ExcludePaths ` + -Format $Format ` + -OutputPath $OutputPath ` + -FailOnUnpinned:$FailOnUnpinned ` + -Threshold $Threshold ` + -Remediate:$Remediate + exit 0 + } + catch { + Write-Error -ErrorAction Continue "Test-DependencyPinning failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error + exit 1 + } +} +#endregion Main Execution diff --git a/scripts/security/Test-SHAStaleness.ps1 b/scripts/security/Test-SHAStaleness.ps1 index 6437b23f..5ab47216 100644 --- a/scripts/security/Test-SHAStaleness.ps1 +++ b/scripts/security/Test-SHAStaleness.ps1 @@ -78,13 +78,8 @@ $ErrorActionPreference = 'Stop' # Import CIHelpers for workflow command escaping Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force -$script:SkipMain = $env:HVE_SKIP_MAIN -eq '1' - -# Ensure logging directory exists -$LogDir = Split-Path -Parent $LogPath -if (!(Test-Path $LogDir)) { - New-Item -ItemType Directory -Path $LogDir -Force | Out-Null -} +# Script-scope collection of stale dependencies (used by multiple functions) +$script:StaleDependencies = @() function Write-SecurityLog { param( @@ -122,9 +117,6 @@ function Write-SecurityLog { } } -# Structure to hold stale dependency information -$StaleDependencies = @() - function Test-GitHubToken { param( [Parameter(Mandatory = $false)] @@ -877,14 +869,44 @@ function Get-ToolStaleness { } #region Main Execution -if (-not $script:SkipMain) { - try { + +function Invoke-SHAStalenessCheck { + [CmdletBinding()] + [OutputType([void])] + param( + [Parameter(Mandatory = $false)] + [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")] + [string]$OutputFormat = "console", + + [Parameter(Mandatory = $false)] + [int]$MaxAge = 30, + + [Parameter(Mandatory = $false)] + [string]$LogPath = "./logs/sha-staleness-monitoring.log", + + [Parameter(Mandatory = $false)] + [string]$OutputPath = "./logs/sha-staleness-results.json", + + [Parameter(Mandatory = $false)] + [switch]$FailOnStale, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 50)] + [int]$GraphQLBatchSize = 20 + ) + + # Ensure logging directory exists (relocated from script scope) + $LogDir = Split-Path -Parent $LogPath + if (!(Test-Path $LogDir)) { + New-Item -ItemType Directory -Path $LogDir -Force | Out-Null + } + Write-SecurityLog "Starting SHA staleness monitoring..." -Level Info Write-SecurityLog "Max age threshold: $MaxAge days" -Level Info Write-SecurityLog "GraphQL batch size: $GraphQLBatchSize queries per request" -Level Info Write-SecurityLog "Output format: $OutputFormat" -Level Info - # Initialize stale dependencies array + # Reset stale dependencies for this run $script:StaleDependencies = @() # Run staleness check for GitHub Actions @@ -900,15 +922,14 @@ if (-not $script:SkipMain) { Write-SecurityLog "Found $(@($staleTools).Count) stale tool(s):" -Level Warning foreach ($tool in $staleTools) { Write-SecurityLog " - $($tool.Tool): $($tool.CurrentVersion) -> $($tool.LatestVersion)" -Level Warning - - # Add to global stale dependencies for output + $script:StaleDependencies += [PSCustomObject]@{ Type = "Tool" File = "scripts/security/tool-checksums.json" Name = $tool.Tool CurrentVersion = $tool.CurrentVersion LatestVersion = $tool.LatestVersion - DaysOld = $null # Not tracked for tools + DaysOld = $null Severity = "Medium" Message = "Tool has newer version available: $($tool.CurrentVersion) -> $($tool.LatestVersion)" } @@ -918,36 +939,36 @@ if (-not $script:SkipMain) { Write-SecurityLog "All tools are up to date" -Level Info } - # Check for errors $errorTools = @($toolResults | Where-Object { $null -ne $_.Error }) if (@($errorTools).Count -gt 0) { Write-SecurityLog "Failed to check $(@($errorTools).Count) tool(s)" -Level Warning } } - # Output results - Write-OutputResult -Dependencies $StaleDependencies -OutputFormat $OutputFormat -OutputPath $OutputPath + Write-OutputResult -Dependencies $script:StaleDependencies -OutputFormat $OutputFormat -OutputPath $OutputPath Write-SecurityLog "SHA staleness monitoring completed" -Level Success - Write-SecurityLog "Stale dependencies found: $(@($StaleDependencies).Count)" -Level Info + Write-SecurityLog "Stale dependencies found: $(@($script:StaleDependencies).Count)" -Level Info - # Exit with appropriate code based on findings and -FailOnStale parameter - if (@($StaleDependencies).Count -gt 0) { - if ($FailOnStale) { - Write-SecurityLog "Exiting with status 1 due to stale dependencies (-FailOnStale specified)" -Level Warning - exit 1 - } - else { - Write-SecurityLog "Stale dependencies found but exiting with status 0 (use -FailOnStale to fail build)" -Level Warning - exit 0 - } + if (@($script:StaleDependencies).Count -gt 0 -and $FailOnStale) { + throw "Stale dependencies detected ($(@($script:StaleDependencies).Count) found)" + } + + if (@($script:StaleDependencies).Count -gt 0) { + Write-SecurityLog "Stale dependencies found but not failing (use -FailOnStale to fail build)" -Level Warning } - exit 0 # All good -} -catch { - Write-Error -ErrorAction Continue "Test SHA Staleness failed: $($_.Exception.Message)" - Write-CIAnnotation -Message $_.Exception.Message -Level Error - exit 1 } + +if ($MyInvocation.InvocationName -ne '.') { + try { + Invoke-SHAStalenessCheck -OutputFormat $OutputFormat -MaxAge $MaxAge -LogPath $LogPath -OutputPath $OutputPath -FailOnStale:$FailOnStale -GraphQLBatchSize $GraphQLBatchSize + exit 0 + } + catch { + Write-Error -ErrorAction Continue "Test-SHAStaleness failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error + exit 1 + } } -#endregion + +#endregion Main Execution diff --git a/scripts/security/Update-ActionSHAPinning.ps1 b/scripts/security/Update-ActionSHAPinning.ps1 index 5b47423d..57b1369b 100644 --- a/scripts/security/Update-ActionSHAPinning.ps1 +++ b/scripts/security/Update-ActionSHAPinning.ps1 @@ -53,8 +53,6 @@ $ErrorActionPreference = 'Stop' # Import CIHelpers for workflow command escaping Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force -$script:SkipMain = $env:HVE_SKIP_MAIN -eq '1' - # Explicit parameter usage to satisfy static analyzer Write-Debug "Parameters: WorkflowPath=$WorkflowPath, OutputReport=$OutputReport, OutputFormat=$OutputFormat, UpdateStale=$UpdateStale" @@ -704,7 +702,7 @@ function Get-SHAForAction { function Update-WorkflowFile { [CmdletBinding(SupportsShouldProcess)] - [OutputType([hashtable])] + [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [string]$FilePath @@ -719,7 +717,7 @@ function Update-WorkflowFile { if (@($actions).Count -eq 0) { Write-SecurityLog "No GitHub Actions found in $FilePath" -Level 'Info' - return @{ + return [PSCustomObject]@{ FilePath = $FilePath ActionsProcessed = 0 ActionsPinned = 0 @@ -779,7 +777,7 @@ function Update-WorkflowFile { } } - return @{ + return [PSCustomObject]@{ FilePath = $FilePath ActionsProcessed = @($actions).Count ActionsPinned = $actionsPinned @@ -790,7 +788,7 @@ function Update-WorkflowFile { } catch { Write-SecurityLog "Error processing $FilePath : $($_.Exception.Message)" -Level 'Error' - return @{ + return [PSCustomObject]@{ FilePath = $FilePath ActionsProcessed = 0 ActionsPinned = 0 @@ -909,11 +907,27 @@ function Set-ContentPreservePermission { } #region Main Execution -if (-not $script:SkipMain) { + +function Invoke-ActionSHAPinningUpdate { + [CmdletBinding(SupportsShouldProcess)] + [OutputType([void])] + param( + [Parameter()] + [string]$WorkflowPath = ".github/workflows", + + [Parameter()] + [switch]$OutputReport, + + [Parameter()] + [ValidateSet("json", "azdo", "github", "console", "BuildWarning", "Summary")] + [string]$OutputFormat = "console", + + [Parameter()] + [switch]$UpdateStale + ) + Set-StrictMode -Version Latest - $ErrorActionPreference = 'Stop' - try { if ($UpdateStale) { Write-SecurityLog "Starting GitHub Actions SHA update process (updating stale pins)..." -Level 'Info' } @@ -940,13 +954,12 @@ if (-not $script:SkipMain) { $results += $result } - # Generate summary $totalActions = ($results | Measure-Object ActionsProcessed -Sum).Sum $totalPinned = ($results | Measure-Object ActionsPinned -Sum).Sum $totalSkipped = ($results | Measure-Object ActionsSkipped -Sum).Sum $workflowsChanged = @($results | Where-Object { $_.PSObject.Properties.Name -contains 'ContentChanged' -and $_.ContentChanged }).Count - Write-SecurityLog "" -Level 'Info' # Empty line for formatting + Write-SecurityLog "" -Level 'Info' Write-SecurityLog "=== SHA Pinning Summary ===" -Level 'Info' Write-SecurityLog "Workflows processed: $(@($workflowFiles).Count)" -Level 'Info' Write-SecurityLog "Workflows changed: $workflowsChanged" -Level 'Success' @@ -954,14 +967,11 @@ if (-not $script:SkipMain) { Write-SecurityLog "Actions SHA-pinned: $totalPinned" -Level 'Success' Write-SecurityLog "Actions requiring manual review: $totalSkipped" -Level 'Warning' - # Export report if requested if ($OutputReport) { $reportPath = Export-SecurityReport -Results $results Write-SecurityLog "Detailed report available at: $reportPath" -Level 'Info' } - # Show actions requiring manual review and add as security issues - # Get manual review actions with their workflow file context $manualReviewActions = @() foreach ($result in $results) { if ($result.PSObject.Properties.Name -contains 'Changes') { @@ -978,12 +988,11 @@ if (-not $script:SkipMain) { } if ($manualReviewActions) { - Write-SecurityLog "" -Level 'Info' # Empty line for formatting + Write-SecurityLog "" -Level 'Info' Write-SecurityLog "=== Actions Requiring Manual SHA Pinning ===" -Level 'Warning' foreach ($action in $manualReviewActions) { Write-SecurityLog " - $($action.Original)" -Level 'Warning' - # Add security issue for unpinned action Add-SecurityIssue -Type "GitHub Actions Security" ` -Severity "Medium" ` -Title "Unpinned GitHub Action" ` @@ -994,20 +1003,25 @@ if (-not $script:SkipMain) { Write-SecurityLog "Please research and add SHA mappings for these actions manually." -Level 'Warning' } - # Output results in requested format $summaryText = "Processed $(@($workflowFiles).Count) workflows, pinned $totalPinned actions, $totalSkipped require manual review" Write-OutputResult -OutputFormat $OutputFormat -Results $script:SecurityIssues -Summary $summaryText if ($WhatIfPreference) { - Write-SecurityLog "" -Level 'Info' # Empty line for formatting + Write-SecurityLog "" -Level 'Info' Write-SecurityLog "WhatIf mode: No files were modified. Run without -WhatIf to apply changes." -Level 'Info' } - - exit 0 -} -catch { - Write-SecurityLog "Critical error in SHA pinning process: $($_.Exception.Message)" -Level 'Error' - Write-CIAnnotation -Message $_.Exception.Message -Level Error - exit 1 } + +if ($MyInvocation.InvocationName -ne '.') { + try { + Invoke-ActionSHAPinningUpdate -WorkflowPath $WorkflowPath -OutputReport:$OutputReport -OutputFormat $OutputFormat -UpdateStale:$UpdateStale + exit 0 + } + catch { + Write-Error -ErrorAction Continue "Update-ActionSHAPinning failed: $($_.Exception.Message)" + Write-CIAnnotation -Message $_.Exception.Message -Level Error + exit 1 + } } + +#endregion Main Execution diff --git a/scripts/tests/Fixtures/Security/npm-violations/test-pkg.json b/scripts/tests/Fixtures/Security/npm-violations/test-pkg.json new file mode 100644 index 00000000..1d1664ce --- /dev/null +++ b/scripts/tests/Fixtures/Security/npm-violations/test-pkg.json @@ -0,0 +1 @@ +{"dependencies":{"lodash":"^4.17.21"}} diff --git a/scripts/tests/linting/Invoke-LinkLanguageCheck.Tests.ps1 b/scripts/tests/linting/Invoke-LinkLanguageCheck.Tests.ps1 index 88c43113..f242d7f4 100644 --- a/scripts/tests/linting/Invoke-LinkLanguageCheck.Tests.ps1 +++ b/scripts/tests/linting/Invoke-LinkLanguageCheck.Tests.ps1 @@ -21,15 +21,12 @@ BeforeAll { Import-Module $script:ModulePath -Force Import-Module $script:CIHelpersPath -Force - $script:OriginalSkipMain = $env:HVE_SKIP_MAIN - $env:HVE_SKIP_MAIN = '1' . $script:ScriptPath } AfterAll { Remove-Module LintingHelpers -Force -ErrorAction SilentlyContinue Remove-Module CIHelpers -Force -ErrorAction SilentlyContinue - $env:HVE_SKIP_MAIN = $script:OriginalSkipMain } #region Link-Lang-Check Invocation Tests diff --git a/scripts/tests/linting/Invoke-PSScriptAnalyzer.Tests.ps1 b/scripts/tests/linting/Invoke-PSScriptAnalyzer.Tests.ps1 index 1ee205c3..b756a047 100644 --- a/scripts/tests/linting/Invoke-PSScriptAnalyzer.Tests.ps1 +++ b/scripts/tests/linting/Invoke-PSScriptAnalyzer.Tests.ps1 @@ -20,6 +20,8 @@ BeforeAll { # Import modules for mocking Import-Module $script:ModulePath -Force Import-Module $script:CIHelpersPath -Force + + . $script:ScriptPath } AfterAll { @@ -43,11 +45,11 @@ Describe 'Invoke-PSScriptAnalyzer Parameter Validation' -Tag 'Unit' { } It 'Accepts ChangedFilesOnly switch' { - { & $script:ScriptPath -ChangedFilesOnly } | Should -Not -Throw + { Invoke-PSScriptAnalyzerCore -ChangedFilesOnly } | Should -Not -Throw } It 'Accepts BaseBranch with ChangedFilesOnly' { - { & $script:ScriptPath -ChangedFilesOnly -BaseBranch 'develop' } | Should -Not -Throw + { Invoke-PSScriptAnalyzerCore -ChangedFilesOnly -BaseBranch 'develop' } | Should -Not -Throw } } @@ -64,12 +66,12 @@ Describe 'Invoke-PSScriptAnalyzer Parameter Validation' -Tag 'Unit' { It 'Uses default config path when not specified' { # Script defaults to scripts/linting/PSScriptAnalyzer.psd1 - { & $script:ScriptPath } | Should -Not -Throw + { Invoke-PSScriptAnalyzerCore } | Should -Not -Throw } It 'Accepts custom config path' { $configPath = Join-Path $PSScriptRoot '../../linting/PSScriptAnalyzer.psd1' - { & $script:ScriptPath -ConfigPath $configPath } | Should -Not -Throw + { Invoke-PSScriptAnalyzerCore -ConfigPath $configPath } | Should -Not -Throw } } @@ -86,7 +88,7 @@ Describe 'Invoke-PSScriptAnalyzer Parameter Validation' -Tag 'Unit' { It 'Accepts custom output path' { $outputPath = Join-Path ([System.IO.Path]::GetTempPath()) 'test-output.json' - { & $script:ScriptPath -OutputPath $outputPath } | Should -Not -Throw + { Invoke-PSScriptAnalyzerCore -OutputPath $outputPath } | Should -Not -Throw } } } @@ -105,7 +107,7 @@ Describe 'PSScriptAnalyzer Module Availability' -Tag 'Unit' { } It 'Reports error when module unavailable' { - { & $script:ScriptPath } | Should -Throw + { Invoke-PSScriptAnalyzerCore } | Should -Throw } } @@ -121,7 +123,7 @@ Describe 'PSScriptAnalyzer Module Availability' -Tag 'Unit' { } It 'Proceeds when module available' { - { & $script:ScriptPath } | Should -Not -Throw + { Invoke-PSScriptAnalyzerCore } | Should -Not -Throw } } } @@ -146,7 +148,7 @@ Describe 'File Discovery' -Tag 'Unit' { return @('script1.ps1', 'script2.ps1') } - & $script:ScriptPath + Invoke-PSScriptAnalyzerCore Should -Invoke Get-FilesRecursive -Times 1 } } @@ -167,7 +169,7 @@ Describe 'File Discovery' -Tag 'Unit' { return @('changed.ps1') } - & $script:ScriptPath -ChangedFilesOnly + Invoke-PSScriptAnalyzerCore -ChangedFilesOnly Should -Invoke Get-ChangedFilesFromGit -Times 1 } @@ -176,7 +178,7 @@ Describe 'File Discovery' -Tag 'Unit' { return @('changed.ps1') } - & $script:ScriptPath -ChangedFilesOnly -BaseBranch 'develop' + Invoke-PSScriptAnalyzerCore -ChangedFilesOnly -BaseBranch 'develop' Should -Invoke Get-ChangedFilesFromGit -Times 1 -ParameterFilter { $BaseBranch -eq 'develop' } @@ -213,14 +215,14 @@ Describe 'CI Integration' -Tag 'Unit' { ) } - & $script:ScriptPath + try { Invoke-PSScriptAnalyzerCore } catch { $null = $_ } Should -Invoke Write-CIAnnotation -Times 1 } It 'Sets CI output for file count' { Mock Invoke-ScriptAnalyzer { @() } - & $script:ScriptPath + Invoke-PSScriptAnalyzerCore Should -Invoke Set-CIOutput -Times 1 -ParameterFilter { $Name -eq 'count' } @@ -268,12 +270,12 @@ Describe 'Output Generation' -Tag 'Unit' { } It 'Creates JSON output file' { - & $script:ScriptPath -OutputPath $script:OutputFile + try { Invoke-PSScriptAnalyzerCore -OutputPath $script:OutputFile } catch { $null = $_ } Test-Path $script:OutputFile | Should -BeTrue } It 'Output file contains valid JSON' { - & $script:ScriptPath -OutputPath $script:OutputFile + try { Invoke-PSScriptAnalyzerCore -OutputPath $script:OutputFile } catch { $null = $_ } { Get-Content $script:OutputFile | ConvertFrom-Json } | Should -Not -Throw } } @@ -296,7 +298,7 @@ Describe 'Exit Code Handling' -Tag 'Unit' { } It 'Returns success when no issues' { - { & $script:ScriptPath } | Should -Not -Throw + { Invoke-PSScriptAnalyzerCore } | Should -Not -Throw } } @@ -323,8 +325,8 @@ Describe 'Exit Code Handling' -Tag 'Unit' { } } - It 'Script completes with issues in output' { - { & $script:ScriptPath } | Should -Not -Throw + It 'Throws when issues found' { + { Invoke-PSScriptAnalyzerCore } | Should -Throw '*issue*' } } } diff --git a/scripts/tests/linting/Invoke-YamlLint.Tests.ps1 b/scripts/tests/linting/Invoke-YamlLint.Tests.ps1 index 8e0db173..84579086 100644 --- a/scripts/tests/linting/Invoke-YamlLint.Tests.ps1 +++ b/scripts/tests/linting/Invoke-YamlLint.Tests.ps1 @@ -24,6 +24,8 @@ BeforeAll { # Create stub function for actionlint so it can be mocked even when not installed function global:actionlint { '[]' } + + . $script:ScriptPath } AfterAll { @@ -49,11 +51,11 @@ Describe 'Invoke-YamlLint Parameter Validation' -Tag 'Unit' { } It 'Accepts ChangedFilesOnly switch' { - { & $script:ScriptPath -ChangedFilesOnly } | Should -Not -Throw + { Invoke-YamlLintCore -ChangedFilesOnly } | Should -Not -Throw } It 'Accepts BaseBranch with ChangedFilesOnly' { - { & $script:ScriptPath -ChangedFilesOnly -BaseBranch 'develop' } | Should -Not -Throw + { Invoke-YamlLintCore -ChangedFilesOnly -BaseBranch 'develop' } | Should -Not -Throw } } @@ -70,7 +72,7 @@ Describe 'Invoke-YamlLint Parameter Validation' -Tag 'Unit' { It 'Accepts custom output path' { $outputPath = Join-Path ([System.IO.Path]::GetTempPath()) 'test-yaml-lint.json' - { & $script:ScriptPath -OutputPath $outputPath } | Should -Not -Throw + { Invoke-YamlLintCore -OutputPath $outputPath } | Should -Not -Throw } } } @@ -83,19 +85,14 @@ Describe 'actionlint Tool Availability' -Tag 'Unit' { Context 'Tool not installed' { BeforeEach { Mock Get-Command { $null } -ParameterFilter { $Name -eq 'actionlint' } - Mock Write-Error {} } It 'Reports error when actionlint not installed' { - & $script:ScriptPath - Should -Invoke Write-Error -Times 1 + { Invoke-YamlLintCore } | Should -Throw '*actionlint is not installed*' } It 'Writes appropriate error message' { - try { & $script:ScriptPath } catch { Write-Verbose 'Expected error' } - Should -Invoke Write-Error -Times 1 -ParameterFilter { - $Message -like '*actionlint is not installed*' - } + { Invoke-YamlLintCore } | Should -Throw '*actionlint is not installed*' } } @@ -111,7 +108,7 @@ Describe 'actionlint Tool Availability' -Tag 'Unit' { } It 'Proceeds when actionlint available' { - { & $script:ScriptPath } | Should -Not -Throw + { Invoke-YamlLintCore } | Should -Not -Throw } } } @@ -140,14 +137,14 @@ Describe 'File Discovery' -Tag 'Unit' { ) } -ParameterFilter { $Path -eq '.github/workflows' } - & $script:ScriptPath + Invoke-YamlLintCore Should -Invoke Get-ChildItem -Times 1 -ParameterFilter { $Path -eq '.github/workflows' } } It 'Returns no files when workflows directory missing' { Mock Test-Path { $false } -ParameterFilter { $Path -eq '.github/workflows' } - & $script:ScriptPath + Invoke-YamlLintCore Should -Invoke Set-CIOutput -Times 1 -ParameterFilter { $Name -eq 'count' -and $Value -eq '0' } } @@ -161,7 +158,7 @@ Describe 'File Discovery' -Tag 'Unit' { ) } -ParameterFilter { $Path -eq '.github/workflows' } - & $script:ScriptPath + Invoke-YamlLintCore # Should only count 2 files (yml and yaml, not json) Should -Invoke Set-CIOutput -Times 1 -ParameterFilter { $Name -eq 'count' -and $Value -eq '2' } } @@ -180,14 +177,14 @@ Describe 'File Discovery' -Tag 'Unit' { It 'Uses Get-ChangedFilesFromGit when ChangedFilesOnly specified' { Mock Get-ChangedFilesFromGit { @('.github/workflows/ci.yml') } - & $script:ScriptPath -ChangedFilesOnly + Invoke-YamlLintCore -ChangedFilesOnly Should -Invoke Get-ChangedFilesFromGit -Times 1 } It 'Passes BaseBranch to Get-ChangedFilesFromGit' { Mock Get-ChangedFilesFromGit { @() } - & $script:ScriptPath -ChangedFilesOnly -BaseBranch 'develop' + Invoke-YamlLintCore -ChangedFilesOnly -BaseBranch 'develop' Should -Invoke Get-ChangedFilesFromGit -Times 1 -ParameterFilter { $BaseBranch -eq 'develop' } @@ -202,7 +199,7 @@ Describe 'File Discovery' -Tag 'Unit' { ) } - & $script:ScriptPath -ChangedFilesOnly + Invoke-YamlLintCore -ChangedFilesOnly # Should only count 2 workflow files Should -Invoke Set-CIOutput -Times 1 -ParameterFilter { $Name -eq 'count' -and $Value -eq '2' } } @@ -219,13 +216,13 @@ Describe 'File Discovery' -Tag 'Unit' { } It 'Sets count and issues to 0 when no files found' { - & $script:ScriptPath + Invoke-YamlLintCore Should -Invoke Set-CIOutput -Times 1 -ParameterFilter { $Name -eq 'count' -and $Value -eq '0' } Should -Invoke Set-CIOutput -Times 1 -ParameterFilter { $Name -eq 'issues' -and $Value -eq '0' } } It 'Exits with code 0 when no files found' { - { & $script:ScriptPath } | Should -Not -Throw + { Invoke-YamlLintCore } | Should -Not -Throw } } } @@ -253,21 +250,21 @@ Describe 'actionlint Output Parsing' -Tag 'Unit' { It 'Handles null output gracefully' { Mock actionlint { $null } - & $script:ScriptPath + Invoke-YamlLintCore Should -Invoke Set-CIOutput -Times 1 -ParameterFilter { $Name -eq 'issues' -and $Value -eq '0' } } It 'Handles "null" string output' { Mock actionlint { 'null' } - & $script:ScriptPath + Invoke-YamlLintCore Should -Invoke Set-CIOutput -Times 1 -ParameterFilter { $Name -eq 'issues' -and $Value -eq '0' } } It 'Handles empty array output' { Mock actionlint { '[]' } - & $script:ScriptPath + Invoke-YamlLintCore Should -Invoke Set-CIOutput -Times 1 -ParameterFilter { $Name -eq 'issues' -and $Value -eq '0' } } } @@ -278,7 +275,7 @@ Describe 'actionlint Output Parsing' -Tag 'Unit' { '{"message":"test error","filepath":".github/workflows/ci.yml","line":10,"column":5}' } - & $script:ScriptPath + try { Invoke-YamlLintCore } catch { $null = $_ } Should -Invoke Write-CIAnnotation -Times 1 Should -Invoke Set-CIOutput -Times 1 -ParameterFilter { $Name -eq 'issues' -and $Value -eq '1' } } @@ -290,7 +287,7 @@ Describe 'actionlint Output Parsing' -Tag 'Unit' { '[{"message":"error 1","filepath":".github/workflows/ci.yml","line":10,"column":5},{"message":"error 2","filepath":".github/workflows/ci.yml","line":20,"column":3}]' } - & $script:ScriptPath + try { Invoke-YamlLintCore } catch { $null = $_ } Should -Invoke Write-CIAnnotation -Times 2 Should -Invoke Set-CIOutput -Times 1 -ParameterFilter { $Name -eq 'issues' -and $Value -eq '2' } } @@ -301,7 +298,7 @@ Describe 'actionlint Output Parsing' -Tag 'Unit' { Mock actionlint { 'not valid json {{{' } Mock Write-Warning {} - & $script:ScriptPath + Invoke-YamlLintCore Should -Invoke Write-Warning -Times 1 Should -Invoke Set-CIOutput -Times 1 -ParameterFilter { $Name -eq 'issues' -and $Value -eq '0' } } @@ -333,7 +330,7 @@ Describe 'Issue Processing' -Tag 'Unit' { '{"message":"property runs-on is required","filepath":".github/workflows/ci.yml","line":15,"column":5}' } - & $script:ScriptPath + try { Invoke-YamlLintCore } catch { $null = $_ } Should -Invoke Write-CIAnnotation -Times 1 -ParameterFilter { $Level -eq 'Error' -and $Message -eq 'property runs-on is required' -and @@ -348,7 +345,7 @@ Describe 'Issue Processing' -Tag 'Unit' { '[{"message":"error 1","filepath":"file1.yml","line":1,"column":1},{"message":"error 2","filepath":"file2.yml","line":2,"column":2}]' } - & $script:ScriptPath + try { Invoke-YamlLintCore } catch { $null = $_ } Should -Invoke Write-CIAnnotation -Times 2 } } @@ -360,7 +357,7 @@ Describe 'Issue Processing' -Tag 'Unit' { } Mock Write-Host {} - & $script:ScriptPath + try { Invoke-YamlLintCore } catch { $null = $_ } # Verify error output format includes file:line:column: message Should -Invoke Write-Host -ParameterFilter { $Object -like '*ci.yml:10:5*test message*' @@ -401,12 +398,12 @@ Describe 'Output Generation' -Tag 'Unit' { It 'Creates JSON output file at specified path' { # Use real filesystem for this test - & $script:ScriptPath -OutputPath $script:OutputFile + Invoke-YamlLintCore -OutputPath $script:OutputFile Test-Path $script:OutputFile | Should -BeTrue } It 'Output file contains valid JSON' { - & $script:ScriptPath -OutputPath $script:OutputFile + Invoke-YamlLintCore -OutputPath $script:OutputFile { Get-Content $script:OutputFile | ConvertFrom-Json } | Should -Not -Throw } } @@ -429,7 +426,7 @@ Describe 'Output Generation' -Tag 'Unit' { $newDir = Join-Path $script:TempDir 'newlogs' $outputPath = Join-Path $newDir 'results.json' - & $script:ScriptPath -OutputPath $outputPath + Invoke-YamlLintCore -OutputPath $outputPath Test-Path $newDir | Should -BeTrue } } @@ -458,21 +455,21 @@ Describe 'CI Integration' -Tag 'Unit' { It 'Sets count output with file count' { Mock actionlint { '[]' } - & $script:ScriptPath + Invoke-YamlLintCore Should -Invoke Set-CIOutput -Times 1 -ParameterFilter { $Name -eq 'count' } } It 'Sets issues output with issue count' { Mock actionlint { '[]' } - & $script:ScriptPath + Invoke-YamlLintCore Should -Invoke Set-CIOutput -Times 1 -ParameterFilter { $Name -eq 'issues' } } It 'Sets errors output with error count' { Mock actionlint { '[]' } - & $script:ScriptPath + Invoke-YamlLintCore Should -Invoke Set-CIOutput -Times 1 -ParameterFilter { $Name -eq 'errors' } } } @@ -483,7 +480,7 @@ Describe 'CI Integration' -Tag 'Unit' { '{"message":"error","filepath":"ci.yml","line":1,"column":1}' } - try { & $script:ScriptPath } catch { Write-Verbose 'Expected error' } + try { Invoke-YamlLintCore } catch { Write-Verbose 'Expected error' } Should -Invoke Set-CIEnv -Times 1 -ParameterFilter { $Name -eq 'YAML_LINT_FAILED' -and $Value -eq 'true' } @@ -492,7 +489,7 @@ Describe 'CI Integration' -Tag 'Unit' { It 'Does not set YAML_LINT_FAILED when no issues' { Mock actionlint { '[]' } - & $script:ScriptPath + Invoke-YamlLintCore Should -Invoke Set-CIEnv -Times 0 -ParameterFilter { $Name -eq 'YAML_LINT_FAILED' } @@ -503,7 +500,7 @@ Describe 'CI Integration' -Tag 'Unit' { It 'Writes success summary when no issues' { Mock actionlint { '[]' } - & $script:ScriptPath + Invoke-YamlLintCore Should -Invoke Write-CIStepSummary -Times 2 } @@ -512,7 +509,7 @@ Describe 'CI Integration' -Tag 'Unit' { '{"message":"error","filepath":"ci.yml","line":1,"column":1}' } - try { & $script:ScriptPath } catch { Write-Verbose 'Expected error' } + try { Invoke-YamlLintCore } catch { Write-Verbose 'Expected error' } Should -Invoke Write-CIStepSummary -Times 2 } } @@ -537,7 +534,7 @@ Describe 'Exit Code Handling' -Tag 'Unit' { It 'Returns success when no files to analyze' { Mock Test-Path { $false } -ParameterFilter { $Path -eq '.github/workflows' } - { & $script:ScriptPath } | Should -Not -Throw + { Invoke-YamlLintCore } | Should -Not -Throw } It 'Returns success when files have no issues' { @@ -547,7 +544,7 @@ Describe 'Exit Code Handling' -Tag 'Unit' { } -ParameterFilter { $Path -eq '.github/workflows' } Mock actionlint { '[]' } - { & $script:ScriptPath } | Should -Not -Throw + { Invoke-YamlLintCore } | Should -Not -Throw } } @@ -561,12 +558,8 @@ Describe 'Exit Code Handling' -Tag 'Unit' { It 'Exits with error when actionlint not installed' { Mock Get-Command { $null } -ParameterFilter { $Name -eq 'actionlint' } - Mock Write-Error {} - & $script:ScriptPath - Should -Invoke Write-Error -Times 1 -ParameterFilter { - $Message -like '*actionlint is not installed*' - } + { Invoke-YamlLintCore } | Should -Throw '*actionlint is not installed*' } It 'Exits with error when issues found' { @@ -581,7 +574,7 @@ Describe 'Exit Code Handling' -Tag 'Unit' { Mock New-Item {} Mock Out-File {} - & $script:ScriptPath + try { Invoke-YamlLintCore } catch { $null = $_ } Should -Invoke Write-CIAnnotation -Times 1 } } diff --git a/scripts/tests/linting/Link-Lang-Check.Tests.ps1 b/scripts/tests/linting/Link-Lang-Check.Tests.ps1 index 2f6ea109..8b646949 100644 --- a/scripts/tests/linting/Link-Lang-Check.Tests.ps1 +++ b/scripts/tests/linting/Link-Lang-Check.Tests.ps1 @@ -15,16 +15,12 @@ BeforeAll { $script:ScriptPath = Join-Path $PSScriptRoot '../../linting/Link-Lang-Check.ps1' - $script:OriginalSkipMain = $env:HVE_SKIP_MAIN - $env:HVE_SKIP_MAIN = '1' - . $script:ScriptPath $script:FixtureDir = Join-Path $PSScriptRoot '../Fixtures/Linting' } AfterAll { - $env:HVE_SKIP_MAIN = $script:OriginalSkipMain } #region Get-GitTextFile Tests @@ -366,9 +362,6 @@ Describe 'ExcludePaths Filtering' -Tag 'Integration' { Context 'Script invocation with ExcludePaths' { BeforeEach { - # Clear HVE_SKIP_MAIN so the script's main block runs during integration tests - $env:HVE_SKIP_MAIN = $null - # Create test directory structure $script:TestsDir = Join-Path $script:TempDir 'scripts/tests/linting' $script:DocsDir = Join-Path $script:TempDir 'docs' @@ -384,11 +377,6 @@ Describe 'ExcludePaths Filtering' -Tag 'Integration' { 'Link: https://docs.microsoft.com/en-us/azure' | Set-Content -Path $docsFile } - AfterEach { - # Restore HVE_SKIP_MAIN for function-only tests - $env:HVE_SKIP_MAIN = '1' - } - It 'Excludes files matching single pattern' { Push-Location $script:TempDir try { @@ -500,3 +488,85 @@ Describe 'ExcludePaths Filtering' -Tag 'Integration' { } #endregion + +#region Invoke-LinkLanguageCheck Tests + +Describe 'Invoke-LinkLanguageCheck' -Tag 'Unit' { + BeforeAll { + Mock Get-GitTextFile { return @('file1.md', 'file2.md') } + Mock Test-Path { return $true } -ParameterFilter { $PathType -eq 'Leaf' } + } + + Context 'No links found' { + BeforeAll { + Mock Find-LinksInFile { return @() } + } + + It 'Outputs empty JSON array when -Fix is not set' { + $result = Invoke-LinkLanguageCheck + $result | Should -Be '[]' + } + + It 'Outputs no-links message when -Fix is set' { + $result = Invoke-LinkLanguageCheck -Fix + $result | Should -Be "No URLs containing 'en-us' were found." + } + } + + Context 'Links found with -Fix' { + BeforeAll { + $script:mockLinks = @( + @{ File = 'file1.md'; LineNumber = 5; OriginalUrl = 'https://learn.microsoft.com/en-us/docs'; FixedUrl = 'https://learn.microsoft.com/docs' } + ) + Mock Find-LinksInFile { return $script:mockLinks } + Mock Repair-AllLink { return 1 } + } + + It 'Calls Repair-AllLink and reports fix count' { + $result = Invoke-LinkLanguageCheck -Fix + $result | Should -BeLike 'Fixed * URLs in 1 files*' + Should -Invoke Repair-AllLink -Times 1 + } + } + + Context 'Links found without -Fix' { + BeforeAll { + $script:mockLinks = @( + @{ File = 'file1.md'; LineNumber = 5; OriginalUrl = 'https://learn.microsoft.com/en-us/docs'; FixedUrl = 'https://learn.microsoft.com/docs' } + ) + Mock Find-LinksInFile { return $script:mockLinks } + Mock ConvertTo-JsonOutput { return @(@{ File = 'file1.md'; Line = 5 }) } + } + + It 'Outputs JSON via ConvertTo-JsonOutput' { + $result = Invoke-LinkLanguageCheck + $result | Should -Not -BeNullOrEmpty + Should -Invoke ConvertTo-JsonOutput -Times 1 + } + } + + Context 'ExcludePaths filtering' { + BeforeAll { + Mock Get-GitTextFile { return @('src/file.md', 'node_modules/pkg/file.md', 'build/out.md') } + Mock Find-LinksInFile { return @() } + } + + It 'Excludes files matching exclusion patterns' { + Invoke-LinkLanguageCheck -ExcludePaths @('node_modules/**', 'build/**') + Should -Invoke Find-LinksInFile -Times 1 + } + } + + Context 'Non-file paths are skipped' { + BeforeAll { + Mock Get-GitTextFile { return @('not-a-file') } + Mock Test-Path { return $false } -ParameterFilter { $PathType -eq 'Leaf' } + Mock Find-LinksInFile { return @() } + } + + It 'Does not call Find-LinksInFile for non-files' { + Invoke-LinkLanguageCheck + Should -Invoke Find-LinksInFile -Times 0 + } + } +} diff --git a/scripts/tests/linting/Markdown-Link-Check.Tests.ps1 b/scripts/tests/linting/Markdown-Link-Check.Tests.ps1 index 73996c09..4bf0fbf9 100644 --- a/scripts/tests/linting/Markdown-Link-Check.Tests.ps1 +++ b/scripts/tests/linting/Markdown-Link-Check.Tests.ps1 @@ -12,9 +12,6 @@ BeforeAll { $script:ScriptPath = Join-Path $PSScriptRoot '../../linting/Markdown-Link-Check.ps1' - $script:OriginalSkipMain = $env:HVE_SKIP_MAIN - $env:HVE_SKIP_MAIN = '1' - . $script:ScriptPath # Import LintingHelpers for mocking @@ -25,7 +22,6 @@ BeforeAll { AfterAll { Remove-Module LintingHelpers -Force -ErrorAction SilentlyContinue - $env:HVE_SKIP_MAIN = $script:OriginalSkipMain } #region Get-MarkdownTarget Tests @@ -257,16 +253,6 @@ Describe 'Markdown-Link-Check Integration' -Tag 'Integration' { $script:LinkCheckScript = Join-Path $PSScriptRoot '../../linting/Markdown-Link-Check.ps1' } - BeforeEach { - # Clear HVE_SKIP_MAIN so the script's main block runs during integration tests - $env:HVE_SKIP_MAIN = $null - } - - AfterEach { - # Restore HVE_SKIP_MAIN for function-only tests - $env:HVE_SKIP_MAIN = '1' - } - AfterAll { if ($null -eq $script:OriginalGHA) { Remove-Item Env:GITHUB_ACTIONS -ErrorAction SilentlyContinue @@ -306,3 +292,59 @@ Describe 'Markdown-Link-Check Integration' -Tag 'Integration' { } #endregion + +#region Invoke-MarkdownLinkCheck Tests + +Describe 'Invoke-MarkdownLinkCheck' -Tag 'Unit' { + BeforeAll { + $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../../..')).Path + $script:FixtureConfig = Join-Path $PSScriptRoot '../Fixtures/Linting/link-check-config.json' + } + + Context 'No markdown files found' { + It 'Throws when Get-MarkdownTarget returns empty' { + Mock Get-MarkdownTarget { return @() } + Mock Resolve-Path { return [PSCustomObject]@{ Path = $script:RepoRoot } } + + { Invoke-MarkdownLinkCheck -Path @('nonexistent') -ConfigPath $script:FixtureConfig } | + Should -Throw '*No markdown files were found to validate*' + } + } + + Context 'CLI not installed' { + It 'Throws when markdown-link-check binary is missing' { + Mock Get-MarkdownTarget { return @('file.md') } + Mock Resolve-Path { return [PSCustomObject]@{ Path = $script:RepoRoot } } + Mock Test-Path { return $false } -ParameterFilter { $LiteralPath -and $LiteralPath -like '*markdown-link-check*' } + + { Invoke-MarkdownLinkCheck -Path @('file.md') -ConfigPath $script:FixtureConfig } | + Should -Throw '*markdown-link-check is not installed*' + } + } + + Context 'Quiet mode base arguments' { + It 'Passes -q flag when Quiet switch is set' { + Mock Get-MarkdownTarget { return @('file.md') } + Mock Resolve-Path { return [PSCustomObject]@{ Path = $script:RepoRoot } } + Mock Test-Path { return $true } -ParameterFilter { $LiteralPath -and $LiteralPath -like '*markdown-link-check*' } + Mock Push-Location { } + Mock Pop-Location { } + Mock Resolve-Path { return [PSCustomObject]@{ Path = "$TestDrive/file.md" } } -ParameterFilter { $LiteralPath -eq 'file.md' } + Mock New-Item { } -ParameterFilter { $ItemType -eq 'Directory' } + Mock Set-Content { } + Mock Write-Host { } + + try { + Invoke-MarkdownLinkCheck -Path @('file.md') -ConfigPath $script:FixtureConfig -Quiet + } + catch { + Write-Verbose "CLI execution expected to fail in test environment: $_" + } + + Should -Invoke Get-MarkdownTarget -Times 1 + Should -Invoke Push-Location -Times 1 + } + } +} + +#endregion diff --git a/scripts/tests/linting/Test-CopyrightHeaders.Tests.ps1 b/scripts/tests/linting/Test-CopyrightHeaders.Tests.ps1 index e85a5dcc..20bba85e 100644 --- a/scripts/tests/linting/Test-CopyrightHeaders.Tests.ps1 +++ b/scripts/tests/linting/Test-CopyrightHeaders.Tests.ps1 @@ -16,14 +16,21 @@ BeforeAll { $script:ScriptPath = Join-Path $PSScriptRoot '../../linting/Test-CopyrightHeaders.ps1' $script:FixturesPath = Join-Path $PSScriptRoot '../Fixtures/CopyrightHeaders' + $script:CIHelpersPath = Join-Path $PSScriptRoot '../../lib/Modules/CIHelpers.psm1' + + # Import modules for mocking + Import-Module $script:CIHelpersPath -Force # Create test fixtures directory if (-not (Test-Path $script:FixturesPath)) { New-Item -ItemType Directory -Path $script:FixturesPath -Force | Out-Null } + + . $script:ScriptPath } AfterAll { + Remove-Module CIHelpers -Force -ErrorAction SilentlyContinue # Cleanup test fixtures if (Test-Path $script:FixturesPath) { Remove-Item -Path $script:FixturesPath -Recurse -Force -ErrorAction SilentlyContinue @@ -115,7 +122,7 @@ Write-Host "Hello World" It 'Detects valid headers in file' { $outputPath = Join-Path $script:FixturesPath 'results.json' - & $script:ScriptPath -Path $script:FixturesPath -FileExtensions @('valid.ps1') -OutputPath $outputPath + Invoke-CopyrightHeaderCheck -Path $script:FixturesPath -FileExtensions @('valid.ps1') -OutputPath $outputPath $results = Get-Content $outputPath | ConvertFrom-Json $validFile = $results.results | Where-Object { $_.file -like '*valid.ps1' } @@ -137,7 +144,7 @@ Write-Host "Hello World" Set-Content -Path (Join-Path $script:FixturesPath 'valid-with-requires.ps1') -Value $validWithRequiresContent $outputPath = Join-Path $script:FixturesPath 'results-requires.json' - & $script:ScriptPath -Path $script:FixturesPath -FileExtensions @('valid-with-requires.ps1') -OutputPath $outputPath + Invoke-CopyrightHeaderCheck -Path $script:FixturesPath -FileExtensions @('valid-with-requires.ps1') -OutputPath $outputPath $results = Get-Content $outputPath | ConvertFrom-Json $validFile = $results.results | Where-Object { $_.file -like '*valid-with-requires.ps1' } @@ -167,7 +174,7 @@ Write-Host "Hello World" Set-Content -Path (Join-Path $script:FixturesPath 'missing-copyright.ps1') -Value $content $outputPath = Join-Path $script:FixturesPath 'results-missing-copyright.json' - & $script:ScriptPath -Path $script:FixturesPath -FileExtensions @('missing-copyright.ps1') -OutputPath $outputPath + Invoke-CopyrightHeaderCheck -Path $script:FixturesPath -FileExtensions @('missing-copyright.ps1') -OutputPath $outputPath $results = Get-Content $outputPath | ConvertFrom-Json $file = $results.results | Where-Object { $_.file -like '*missing-copyright.ps1' } @@ -187,7 +194,7 @@ Write-Host "Hello World" Set-Content -Path (Join-Path $script:FixturesPath 'missing-spdx.ps1') -Value $content $outputPath = Join-Path $script:FixturesPath 'results-missing-spdx.json' - & $script:ScriptPath -Path $script:FixturesPath -FileExtensions @('missing-spdx.ps1') -OutputPath $outputPath + Invoke-CopyrightHeaderCheck -Path $script:FixturesPath -FileExtensions @('missing-spdx.ps1') -OutputPath $outputPath $results = Get-Content $outputPath | ConvertFrom-Json $file = $results.results | Where-Object { $_.file -like '*missing-spdx.ps1' } @@ -206,7 +213,7 @@ Write-Host "Hello World" Set-Content -Path (Join-Path $script:FixturesPath 'missing-both.ps1') -Value $content $outputPath = Join-Path $script:FixturesPath 'results-missing-both.json' - & $script:ScriptPath -Path $script:FixturesPath -FileExtensions @('missing-both.ps1') -OutputPath $outputPath + Invoke-CopyrightHeaderCheck -Path $script:FixturesPath -FileExtensions @('missing-both.ps1') -OutputPath $outputPath $results = Get-Content $outputPath | ConvertFrom-Json $file = $results.results | Where-Object { $_.file -like '*missing-both.ps1' } @@ -243,7 +250,7 @@ Write-Host "Headers too late" Set-Content -Path (Join-Path $script:FixturesPath 'headers-too-late.ps1') -Value $content $outputPath = Join-Path $script:FixturesPath 'results-headers-too-late.json' - & $script:ScriptPath -Path $script:FixturesPath -FileExtensions @('headers-too-late.ps1') -OutputPath $outputPath + Invoke-CopyrightHeaderCheck -Path $script:FixturesPath -FileExtensions @('headers-too-late.ps1') -OutputPath $outputPath $results = Get-Content $outputPath | ConvertFrom-Json $file = $results.results | Where-Object { $_.file -like '*headers-too-late.ps1' } @@ -261,26 +268,25 @@ Write-Host "Headers too late" Describe 'Test-CopyrightHeaders Parameters' -Tag 'Unit' { It 'Accepts Path parameter' { - { & $script:ScriptPath -Path $script:FixturesPath -OutputPath (Join-Path $script:FixturesPath 'test.json') } | Should -Not -Throw + { Invoke-CopyrightHeaderCheck -Path $script:FixturesPath -OutputPath (Join-Path $script:FixturesPath 'test.json') } | Should -Not -Throw } It 'Accepts FileExtensions parameter' { - { & $script:ScriptPath -Path $script:FixturesPath -FileExtensions @('*.ps1') -OutputPath (Join-Path $script:FixturesPath 'test.json') } | Should -Not -Throw + { Invoke-CopyrightHeaderCheck -Path $script:FixturesPath -FileExtensions @('*.ps1') -OutputPath (Join-Path $script:FixturesPath 'test.json') } | Should -Not -Throw } It 'Accepts ExcludePaths parameter' { - { & $script:ScriptPath -Path $script:FixturesPath -ExcludePaths @('node_modules') -OutputPath (Join-Path $script:FixturesPath 'test.json') } | Should -Not -Throw + { Invoke-CopyrightHeaderCheck -Path $script:FixturesPath -ExcludePaths @('node_modules') -OutputPath (Join-Path $script:FixturesPath 'test.json') } | Should -Not -Throw } - It 'Returns exit code 1 with FailOnMissing when files missing headers' { + It 'Throws with FailOnMissing when files missing headers' { $content = @" #!/usr/bin/env pwsh Write-Host "No headers" "@ Set-Content -Path (Join-Path $script:FixturesPath 'no-headers.ps1') -Value $content - $null = & $script:ScriptPath -Path $script:FixturesPath -FileExtensions @('no-headers.ps1') -OutputPath (Join-Path $script:FixturesPath 'fail-test.json') -FailOnMissing 2>&1 - $LASTEXITCODE | Should -Be 1 + { Invoke-CopyrightHeaderCheck -Path $script:FixturesPath -FileExtensions @('no-headers.ps1') -OutputPath (Join-Path $script:FixturesPath 'fail-test.json') -FailOnMissing } | Should -Throw '*missing required headers*' } } @@ -303,7 +309,7 @@ Write-Host "Test" Set-Content -Path (Join-Path $script:FixturesPath 'output-test.ps1') -Value $content $script:OutputPath = Join-Path $script:FixturesPath 'output-format.json' - & $script:ScriptPath -Path $script:FixturesPath -FileExtensions @('output-test.ps1') -OutputPath $script:OutputPath + Invoke-CopyrightHeaderCheck -Path $script:FixturesPath -FileExtensions @('output-test.ps1') -OutputPath $script:OutputPath } It 'Outputs valid JSON' { diff --git a/scripts/tests/linting/Validate-MarkdownFrontmatter.Tests.ps1 b/scripts/tests/linting/Validate-MarkdownFrontmatter.Tests.ps1 index ce0d78bb..fea1f2f8 100644 --- a/scripts/tests/linting/Validate-MarkdownFrontmatter.Tests.ps1 +++ b/scripts/tests/linting/Validate-MarkdownFrontmatter.Tests.ps1 @@ -363,6 +363,24 @@ Describe 'Get-SchemaForFile' -Tag 'Unit' { $result | Should -Match 'base-frontmatter\.schema\.json' } } + + Context 'Auto RepoRoot resolution' { + It 'Auto-detects repo root when RepoRoot is not specified' { + $result = Get-SchemaForFile -FilePath 'docs/guide/readme.md' -SchemaDirectory $script:SchemaDir + $result | Should -Match 'docs-frontmatter\.schema\.json' + } + + It 'Returns null when no .git directory is found' { + $isolatedDir = Join-Path $TestDrive 'isolated-schemas' + New-Item -ItemType Directory -Path $isolatedDir -Force | Out-Null + '{"mappings": [], "defaultSchema": "base.schema.json"}' | Set-Content -Path (Join-Path $isolatedDir 'schema-mapping.json') + + Mock Test-Path { return $false } -ParameterFilter { $Path -like '*\.git' -or $Path -like '*/.git' } + + $result = Get-SchemaForFile -FilePath 'test.md' -SchemaDirectory $isolatedDir 3>$null + $result | Should -BeNullOrEmpty + } + } } #endregion @@ -975,6 +993,81 @@ Content Should -Invoke Get-ChangedMarkdownFileGroup -ParameterFilter { $BaseBranch -eq 'develop' } } } + + Context 'EnableSchemaValidation mode' { + BeforeEach { + @" +--- +title: Schema Test Doc +description: Valid test document for schema overlay +--- + +# Test Content +"@ | Set-Content -Path "$script:TestRepoRoot/docs/schema-test.md" -Encoding UTF8 + } + + It 'Invokes schema validation on files with frontmatter' { + Mock Initialize-JsonSchemaValidation { return $true } + Mock Get-SchemaForFile { return (Join-Path $script:SchemaDir 'docs-frontmatter.schema.json') } + Mock Test-JsonSchemaValidation { + return [PSCustomObject]@{ IsValid = $true; Errors = @(); Warnings = @() } + } + + $null = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/schema-test.md") -EnableSchemaValidation -SkipFooterValidation + + Should -Invoke Get-SchemaForFile -Times 1 + Should -Invoke Test-JsonSchemaValidation -Times 1 + } + + It 'Writes warnings when schema validation reports errors' { + Mock Initialize-JsonSchemaValidation { return $true } + Mock Get-SchemaForFile { return (Join-Path $script:SchemaDir 'docs-frontmatter.schema.json') } + Mock Test-JsonSchemaValidation { + return [PSCustomObject]@{ IsValid = $false; Errors = @('Missing required field: ms.date'); Warnings = @() } + } + + $null = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/schema-test.md") -EnableSchemaValidation -SkipFooterValidation -WarningVariable warnings 3>$null + + $schemaWarnings = $warnings | Where-Object { $_ -match 'JSON Schema validation errors' -or $_ -match 'ms\.date' } + $schemaWarnings | Should -Not -BeNullOrEmpty + } + + It 'Skips schema check when file has no frontmatter' { + @" +# No Frontmatter + +Just content without YAML. +"@ | Set-Content -Path "$script:TestRepoRoot/docs/no-fm-schema.md" -Encoding UTF8 + + Mock Initialize-JsonSchemaValidation { return $true } + Mock Get-SchemaForFile {} + Mock Test-JsonSchemaValidation {} + + $null = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/no-fm-schema.md") -EnableSchemaValidation -SkipFooterValidation + + Should -Invoke Get-SchemaForFile -Times 0 + } + + It 'Skips Test-JsonSchemaValidation when no schema matches file' { + Mock Initialize-JsonSchemaValidation { return $true } + Mock Get-SchemaForFile { return $null } + Mock Test-JsonSchemaValidation {} + + $null = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/schema-test.md") -EnableSchemaValidation -SkipFooterValidation + + Should -Invoke Get-SchemaForFile -Times 1 + Should -Invoke Test-JsonSchemaValidation -Times 0 + } + + It 'Skips overlay entirely when Initialize-JsonSchemaValidation returns false' { + Mock Initialize-JsonSchemaValidation { return $false } + Mock Get-SchemaForFile {} + + $null = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/schema-test.md") -EnableSchemaValidation -SkipFooterValidation + + Should -Invoke Get-SchemaForFile -Times 0 + } + } } #endregion @@ -1188,6 +1281,29 @@ Describe 'CI Environment Integration' -Tag 'Unit' { # Step summary should be written Test-Path $stepSummaryPath | Should -BeTrue } + + It 'Writes fail step summary and sets FRONTMATTER_VALIDATION_FAILED env var' { + Mock Set-CIEnv { } + + $env:GITHUB_ACTIONS = 'true' + $stepSummaryPath = Join-Path $TestDrive 'step-summary-fail.md' + $env:GITHUB_STEP_SUMMARY = $stepSummaryPath + + # File without frontmatter generates warning; -WarningsAsErrors makes GetExitCode non-zero + $testFile = Join-Path $TestDrive 'fail-ci.md' + Set-Content $testFile "# No Frontmatter`n`nContent without YAML front matter." + + $null = Test-FrontmatterValidation -Files @($testFile) -WarningsAsErrors -SkipFooterValidation + + Test-Path $stepSummaryPath | Should -BeTrue + $content = Get-Content $stepSummaryPath -Raw + $content | Should -Match 'Failed' + + # Set-CIEnv writes to GITHUB_ENV file, not in-process env vars + Should -Invoke Set-CIEnv -Times 1 -Exactly -ParameterFilter { + $Name -eq 'FRONTMATTER_VALIDATION_FAILED' -and $Value -eq 'true' + } + } } Context 'Main execution error handling with GitHub Actions' { diff --git a/scripts/tests/security/SecurityClasses.Tests.ps1 b/scripts/tests/security/SecurityClasses.Tests.ps1 new file mode 100644 index 00000000..f31f3ff9 --- /dev/null +++ b/scripts/tests/security/SecurityClasses.Tests.ps1 @@ -0,0 +1,202 @@ +#Requires -Modules Pester +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +using module ..\..\security\Modules\SecurityClasses.psm1 + +Describe 'DependencyViolation' -Tag 'Unit' { + Context 'Default constructor' { + It 'Initializes with empty Metadata hashtable' { + $v = [DependencyViolation]::new() + $v.Metadata | Should -BeOfType [hashtable] + $v.Metadata.Count | Should -Be 0 + } + + It 'Has null/default string properties' { + $v = [DependencyViolation]::new() + $v.File | Should -BeNullOrEmpty + $v.Name | Should -BeNullOrEmpty + $v.Line | Should -Be 0 + } + } + + Context 'Parameterized constructor' { + BeforeAll { + $script:violation = [DependencyViolation]::new( + 'workflow.yml', 10, 'github-actions', 'actions/checkout', 'High', 'Not SHA-pinned' + ) + } + + It 'Sets File property' { + $script:violation.File | Should -Be 'workflow.yml' + } + + It 'Sets Line property' { + $script:violation.Line | Should -Be 10 + } + + It 'Sets Type property' { + $script:violation.Type | Should -Be 'github-actions' + } + + It 'Sets Name property' { + $script:violation.Name | Should -Be 'actions/checkout' + } + + It 'Sets Severity property' { + $script:violation.Severity | Should -Be 'High' + } + + It 'Sets Description property' { + $script:violation.Description | Should -Be 'Not SHA-pinned' + } + + It 'Initializes Metadata as empty hashtable' { + $script:violation.Metadata | Should -BeOfType [hashtable] + $script:violation.Metadata.Count | Should -Be 0 + } + } + + Context 'ViolationType ValidateSet' { + It 'Accepts valid ViolationType values' -ForEach @( + @{ Value = 'Unpinned' } + @{ Value = 'Stale' } + @{ Value = 'VersionMismatch' } + @{ Value = 'MissingVersionComment' } + @{ Value = '' } + ) { + $v = [DependencyViolation]::new() + $v.ViolationType = $Value + $v.ViolationType | Should -Be $Value + } + + It 'Rejects invalid ViolationType' { + $v = [DependencyViolation]::new() + { $v.ViolationType = 'InvalidType' } | Should -Throw + } + } +} + +Describe 'ComplianceReport' -Tag 'Unit' { + Context 'Default constructor' { + BeforeAll { + $script:report = [ComplianceReport]::new() + } + + It 'Sets Timestamp to current time' { + $script:report.Timestamp | Should -BeOfType [datetime] + ($script:report.Timestamp - (Get-Date)).TotalSeconds | Should -BeLessThan 5 + } + + It 'Initializes empty Violations array' { + $script:report.Violations | Should -HaveCount 0 + } + + It 'Initializes empty Summary hashtable' { + $script:report.Summary | Should -BeOfType [hashtable] + } + + It 'Initializes empty Metadata hashtable' { + $script:report.Metadata | Should -BeOfType [hashtable] + } + } + + Context 'Parameterized constructor' { + It 'Sets ScanPath' { + $report = [ComplianceReport]::new('/repo') + $report.ScanPath | Should -Be '/repo' + } + + It 'Initializes collections' { + $report = [ComplianceReport]::new('/repo') + $report.Violations | Should -HaveCount 0 + $report.Summary | Should -BeOfType [hashtable] + $report.Metadata | Should -BeOfType [hashtable] + } + } + + Context 'AddViolation' { + It 'Appends violation and updates UnpinnedDependencies count' { + $report = [ComplianceReport]::new('/repo') + $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'desc') + $report.AddViolation($v) + $report.Violations | Should -HaveCount 1 + $report.UnpinnedDependencies | Should -Be 1 + } + + It 'Tracks multiple violations' { + $report = [ComplianceReport]::new('/repo') + $report.AddViolation([DependencyViolation]::new('a.yml', 1, 't', 'n1', 'High', 'd')) + $report.AddViolation([DependencyViolation]::new('b.yml', 2, 't', 'n2', 'Low', 'd')) + $report.Violations | Should -HaveCount 2 + $report.UnpinnedDependencies | Should -Be 2 + } + } + + Context 'CalculateScore' { + It 'Computes percentage when TotalDependencies > 0' { + $report = [ComplianceReport]::new('/repo') + $report.TotalDependencies = 10 + $report.PinnedDependencies = 8 + $report.CalculateScore() + $report.ComplianceScore | Should -Be 80.0 + } + + It 'Returns 100 when TotalDependencies is zero' { + $report = [ComplianceReport]::new('/repo') + $report.TotalDependencies = 0 + $report.CalculateScore() + $report.ComplianceScore | Should -Be 100.0 + } + + It 'Rounds to two decimal places' { + $report = [ComplianceReport]::new('/repo') + $report.TotalDependencies = 3 + $report.PinnedDependencies = 1 + $report.CalculateScore() + $report.ComplianceScore | Should -Be 33.33 + } + } + + Context 'ToHashtable' { + BeforeAll { + $script:report = [ComplianceReport]::new('/repo') + $script:report.TotalFiles = 5 + $script:report.ScannedFiles = 3 + $script:report.TotalDependencies = 10 + $script:report.PinnedDependencies = 8 + $script:report.UnpinnedDependencies = 2 + $script:report.ComplianceScore = 80.0 + $script:ht = $script:report.ToHashtable() + } + + It 'Returns hashtable with 11 keys' { + $script:ht | Should -BeOfType [hashtable] + $script:ht.Keys.Count | Should -Be 11 + } + + It 'Includes all expected keys' -ForEach @( + @{ Key = 'ScanPath' } + @{ Key = 'Timestamp' } + @{ Key = 'TotalFiles' } + @{ Key = 'ScannedFiles' } + @{ Key = 'TotalDependencies' } + @{ Key = 'PinnedDependencies' } + @{ Key = 'UnpinnedDependencies' } + @{ Key = 'ComplianceScore' } + @{ Key = 'Violations' } + @{ Key = 'Summary' } + @{ Key = 'Metadata' } + ) { + $script:ht.ContainsKey($Key) | Should -BeTrue + } + + It 'Formats Timestamp as ISO 8601 string' { + $script:ht['Timestamp'] | Should -Match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$' + } + + It 'Preserves numeric values' { + $script:ht['TotalFiles'] | Should -Be 5 + $script:ht['ComplianceScore'] | Should -Be 80.0 + } + } +} diff --git a/scripts/tests/security/Test-ActionVersionConsistency.Tests.ps1 b/scripts/tests/security/Test-ActionVersionConsistency.Tests.ps1 index a91dbe44..b9fd640c 100644 --- a/scripts/tests/security/Test-ActionVersionConsistency.Tests.ps1 +++ b/scripts/tests/security/Test-ActionVersionConsistency.Tests.ps1 @@ -10,13 +10,11 @@ using module ../../security/Modules/SecurityClasses.psm1 .DESCRIPTION Tests version consistency checking functions without executing the main script. - Uses HVE_SKIP_MAIN environment variable to prevent main block execution. + Uses dot-source guard pattern for function isolation. #> BeforeAll { $scriptPath = Join-Path $PSScriptRoot '../../security/Test-ActionVersionConsistency.ps1' - $script:OriginalSkipMain = $env:HVE_SKIP_MAIN - $env:HVE_SKIP_MAIN = '1' . $scriptPath $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1' @@ -29,7 +27,6 @@ BeforeAll { AfterAll { Restore-CIEnvironment - $env:HVE_SKIP_MAIN = $script:OriginalSkipMain } Describe 'Write-ConsistencyLog' -Tag 'Unit' { @@ -527,10 +524,8 @@ Describe 'Main Script Execution' -Tag 'Unit' { Copy-Item -Path (Join-Path $script:FixturesPath 'version-mismatch-a.yml') -Destination $script:TestWorkspace Copy-Item -Path (Join-Path $script:FixturesPath 'version-mismatch-b.yml') -Destination $script:TestWorkspace - # Clear HVE_SKIP_MAIN so subprocess runs main block (it's inherited from Pester's BeforeAll) $tempScript = Join-Path $script:TestWorkspace 'run-test.ps1' $scriptContent = @" -`$env:HVE_SKIP_MAIN = '' & '$($script:TestScript)' -Path '$($script:TestWorkspace)' -Format Json -FailOnMismatch exit `$LASTEXITCODE "@ @@ -542,10 +537,8 @@ exit `$LASTEXITCODE It 'Returns exit code 1 when FailOnMissingComment and missing comments exist' { Copy-Item -Path (Join-Path $script:FixturesPath 'missing-version-comment.yml') -Destination $script:TestWorkspace - # Clear HVE_SKIP_MAIN so subprocess runs main block (it's inherited from Pester's BeforeAll) $tempScript = Join-Path $script:TestWorkspace 'run-test.ps1' $scriptContent = @" -`$env:HVE_SKIP_MAIN = '' & '$($script:TestScript)' -Path '$($script:TestWorkspace)' -Format Json -FailOnMissingComment exit `$LASTEXITCODE "@ diff --git a/scripts/tests/security/Test-DependencyPinning.Tests.ps1 b/scripts/tests/security/Test-DependencyPinning.Tests.ps1 index d442a4a8..c032f2ea 100644 --- a/scripts/tests/security/Test-DependencyPinning.Tests.ps1 +++ b/scripts/tests/security/Test-DependencyPinning.Tests.ps1 @@ -355,17 +355,6 @@ Describe 'Dot-sourced execution protection' -Tag 'Unit' { Test-Path $tempOutputPath | Should -BeFalse } - It 'Writes error message when dot-sourced' { - # Arrange - $testScript = Join-Path $PSScriptRoot '../../security/Test-DependencyPinning.ps1' - - # Act - Invoke in new process to test dot-sourcing error handling - $result = pwsh -Command ". '$testScript'" 2>&1 - $errorOutput = $result | Where-Object { $_ -match 'dot-sourced' -or $_ -match 'will not execute' } - - # Assert - Should write error message about dot-sourcing - $errorOutput | Should -Not -BeNullOrEmpty - } } } @@ -710,3 +699,153 @@ Describe 'Get-NpmDependencyViolations' -Tag 'Unit' { } } } + +Describe 'Get-RemediationSuggestion' -Tag 'Unit' { + Context 'Without -Remediate flag' { + It 'Returns enable-flag message' { + $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'actions/checkout', 'High', 'desc') + $v.Version = 'v4' + $result = Get-RemediationSuggestion -Violation $v + $result | Should -BeLike '*Enable -Remediate flag*' + } + } + + Context 'GitHub Actions with -Remediate' { + It 'Resolves SHA from API and returns pin suggestion' { + $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'actions/checkout', 'High', 'desc') + $v.Version = 'v4' + $fakeSha = 'a'.PadRight(40, 'b') + Mock Invoke-RestMethod { return @{ sha = $fakeSha } } + $result = Get-RemediationSuggestion -Violation $v -Remediate + $result | Should -BeLike "Pin to SHA: uses: actions/checkout@$fakeSha*" + } + + It 'Returns manual fallback when API throws' { + $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'actions/checkout', 'High', 'desc') + $v.Version = 'v4' + Mock Invoke-RestMethod { throw 'API error' } + Mock Write-PinningLog {} + $result = Get-RemediationSuggestion -Violation $v -Remediate + $result | Should -Be 'Manually research and pin to immutable reference' + } + } + + Context 'Non-github-actions type with -Remediate' { + It 'Returns generic research message' { + $v = [DependencyViolation]::new('req.txt', 1, 'pip', 'requests', 'Medium', 'desc') + $v.Version = '2.31.0' + $result = Get-RemediationSuggestion -Violation $v -Remediate + $result | Should -BeLike '*Research and pin*pip*' + } + } +} + +Describe 'Get-DependencyViolation with ValidationFunc' -Tag 'Unit' { + Context 'npm type triggers ValidationFunc path' { + BeforeAll { + $script:npmFixturePath = Join-Path $script:SecurityFixturesPath 'npm-violations' + if (-not (Test-Path $script:npmFixturePath)) { + New-Item -ItemType Directory -Path $script:npmFixturePath -Force | Out-Null + } + $script:pkgPath = Join-Path $script:npmFixturePath 'test-pkg.json' + Set-Content -Path $script:pkgPath -Value '{"dependencies":{"lodash":"^4.17.21"}}' + } + + It 'Uses ValidationFunc instead of regex patterns' { + $fileInfo = @{ + Path = $script:pkgPath + Type = 'npm' + RelativePath = 'test-pkg.json' + } + $violations = Get-DependencyViolation -FileInfo $fileInfo + $violations | Should -Not -BeNullOrEmpty + $violations[0].GetType().Name | Should -Be 'DependencyViolation' + } + + It 'Sets File from FileInfo when missing' { + $fileInfo = @{ + Path = $script:pkgPath + Type = 'npm' + RelativePath = 'test-pkg.json' + } + $violations = Get-DependencyViolation -FileInfo $fileInfo + $violations | ForEach-Object { $_.File | Should -Not -BeNullOrEmpty } + } + } +} + +Describe 'Invoke-DependencyPinningAnalysis' -Tag 'Unit' { + BeforeAll { + Mock Get-FilesToScan { return @() } + Mock Get-ComplianceReportData { + return @{ + ComplianceScore = 100.0 + TotalDependencies = 0 + UnpinnedDependencies = 0 + Violations = @() + } + } + Mock Export-ComplianceReport {} + Mock Export-CICDArtifact {} + } + + Context 'All dependencies pinned' { + It 'Logs success message without throwing' { + { Invoke-DependencyPinningAnalysis -Path TestDrive: } | Should -Not -Throw + } + } + + Context 'Violations below threshold with -FailOnUnpinned' { + BeforeAll { + Mock Get-FilesToScan { + return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' }) + } + Mock Get-DependencyViolation { + $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'High', 'Not pinned') + return @($v) + } + Mock Get-RemediationSuggestion { return 'pin it' } + Mock Get-ComplianceReportData { + return @{ + ComplianceScore = 50.0 + TotalDependencies = 2 + UnpinnedDependencies = 1 + Violations = @() + } + } + } + + It 'Throws when score below threshold and -FailOnUnpinned' { + { Invoke-DependencyPinningAnalysis -Path TestDrive: -FailOnUnpinned -Threshold 80 } | Should -Throw '*below threshold*' + } + + It 'Does not throw in soft-fail mode' { + { Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80 } | Should -Not -Throw + } + } + + Context 'Score meets threshold' { + BeforeAll { + Mock Get-FilesToScan { + return @(@{ Path = 'TestDrive:\f.yml'; Type = 'github-actions'; RelativePath = 'f.yml' }) + } + Mock Get-DependencyViolation { + $v = [DependencyViolation]::new('f.yml', 1, 'github-actions', 'a/b', 'Low', 'desc') + return @($v) + } + Mock Get-RemediationSuggestion { return 'pin it' } + Mock Get-ComplianceReportData { + return @{ + ComplianceScore = 90.0 + TotalDependencies = 10 + UnpinnedDependencies = 1 + Violations = @() + } + } + } + + It 'Does not throw when score meets threshold' { + { Invoke-DependencyPinningAnalysis -Path TestDrive: -Threshold 80 } | Should -Not -Throw + } + } +} diff --git a/scripts/tests/security/Test-SHAStaleness.Tests.ps1 b/scripts/tests/security/Test-SHAStaleness.Tests.ps1 index c4a73a02..7541915d 100644 --- a/scripts/tests/security/Test-SHAStaleness.Tests.ps1 +++ b/scripts/tests/security/Test-SHAStaleness.Tests.ps1 @@ -1,4 +1,4 @@ -#Requires -Modules Pester +#Requires -Modules Pester # Copyright (c) Microsoft Corporation. # SPDX-License-Identifier: MIT @@ -8,13 +8,11 @@ .DESCRIPTION Tests the staleness checking functions without executing the main script. - Uses AST function extraction to avoid running main execution block. + Uses dot-source guard pattern for function isolation. #> BeforeAll { $scriptPath = Join-Path $PSScriptRoot '../../security/Test-SHAStaleness.ps1' - $script:OriginalSkipMain = $env:HVE_SKIP_MAIN - $env:HVE_SKIP_MAIN = '1' . $scriptPath $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1' @@ -30,7 +28,6 @@ BeforeAll { AfterAll { # Restore environment after tests Restore-CIEnvironment - $env:HVE_SKIP_MAIN = $script:OriginalSkipMain } Describe 'Test-GitHubToken' -Tag 'Unit' { @@ -326,9 +323,6 @@ Describe 'Main Script Execution' { Context 'Array coercion in main execution block' { BeforeEach { - # Clear HVE_SKIP_MAIN so script actually runs main block - $env:HVE_SKIP_MAIN = $null - # Create workflow with SHA-pinned action $workflowContent = @' name: Test @@ -346,8 +340,6 @@ jobs: } AfterEach { - # Restore HVE_SKIP_MAIN - $env:HVE_SKIP_MAIN = '1' # Return to original location Set-Location $script:OriginalLocation } @@ -464,12 +456,10 @@ jobs: Context 'CI environment integration' { BeforeEach { - # Clear HVE_SKIP_MAIN so script actually runs main block - $env:HVE_SKIP_MAIN = $null - # Save original environment $script:OriginalGHA = $env:GITHUB_ACTIONS $script:OriginalADO = $env:TF_BUILD + $script:OriginalGHOutput = $env:GITHUB_OUTPUT # Create test workflow $workflowContent = @' @@ -488,10 +478,9 @@ jobs: } AfterEach { - # Restore HVE_SKIP_MAIN - $env:HVE_SKIP_MAIN = '1' $env:GITHUB_ACTIONS = $script:OriginalGHA $env:TF_BUILD = $script:OriginalADO + $env:GITHUB_OUTPUT = $script:OriginalGHOutput Set-Location $script:OriginalLocation } @@ -553,14 +542,10 @@ jobs: Context 'Empty and edge case scenarios' { BeforeEach { - # Clear HVE_SKIP_MAIN so script actually runs main block - $env:HVE_SKIP_MAIN = $null Set-Location $script:TestRepo } AfterEach { - # Restore HVE_SKIP_MAIN - $env:HVE_SKIP_MAIN = '1' Set-Location $script:OriginalLocation } @@ -647,3 +632,347 @@ jobs: } } } + +Describe 'Get-BulkGitHubActionsStaleness' -Tag 'Unit' { + BeforeAll { + Save-CIEnvironment + } + AfterAll { + Restore-CIEnvironment + } + + Context 'Token resolution' { + BeforeEach { + $env:GITHUB_TOKEN = '' + $env:SYSTEM_ACCESSTOKEN = '' + $env:GH_TOKEN = '' + $env:BUILD_REPOSITORY_PROVIDER = '' + } + + It 'Uses GITHUB_TOKEN when available' { + $env:GITHUB_TOKEN = 'ghp_test_token_123' + Mock Test-GitHubToken { return @{ Valid = $true; Authenticated = $true; RateLimit = @{ remaining = 5000 }; Message = '' } } + Mock Invoke-GitHubAPIWithRetry { + return @{ + data = @{ + rateLimit = @{ remaining = 5000 } + repo0 = @{ defaultBranchRef = @{ target = @{ oid = 'bbbb' * 10; committedDate = (Get-Date).ToString('o') } } } + } + } + } + + $sha = 'aaaa' * 10 + $null = Get-BulkGitHubActionsStaleness -ActionRepos @('owner/repo') -ShaToActionMap @{ + "owner/repo@$sha" = @{ Repo = 'owner/repo'; SHA = $sha; File = 'test.yml' } + } + + Should -Invoke Test-GitHubToken -Times 1 + } + + It 'Falls back to GH_TOKEN when GITHUB_TOKEN is empty' { + $env:GH_TOKEN = 'ghp_fallback_token' + Mock Test-GitHubToken { return @{ Valid = $true; Authenticated = $true; RateLimit = @{ remaining = 5000 }; Message = '' } } + Mock Invoke-GitHubAPIWithRetry { + return @{ + data = @{ + rateLimit = @{ remaining = 5000 } + repo0 = @{ defaultBranchRef = @{ target = @{ oid = 'bbbb' * 10; committedDate = (Get-Date).ToString('o') } } } + } + } + } + + $sha = 'aaaa' * 10 + $null = Get-BulkGitHubActionsStaleness -ActionRepos @('owner/repo') -ShaToActionMap @{ + "owner/repo@$sha" = @{ Repo = 'owner/repo'; SHA = $sha; File = 'test.yml' } + } + + Should -Invoke Test-GitHubToken -Times 1 + } + + It 'Uses SYSTEM_ACCESSTOKEN for GitHub-hosted ADO repos' { + $env:SYSTEM_ACCESSTOKEN = 'ado_token' + $env:BUILD_REPOSITORY_PROVIDER = 'GitHub' + Mock Test-GitHubToken { return @{ Valid = $true; Authenticated = $true; RateLimit = @{ remaining = 5000 }; Message = '' } } + Mock Invoke-GitHubAPIWithRetry { + return @{ + data = @{ + rateLimit = @{ remaining = 5000 } + repo0 = @{ defaultBranchRef = @{ target = @{ oid = 'bbbb' * 10; committedDate = (Get-Date).ToString('o') } } } + } + } + } + + $sha = 'aaaa' * 10 + $null = Get-BulkGitHubActionsStaleness -ActionRepos @('owner/repo') -ShaToActionMap @{ + "owner/repo@$sha" = @{ Repo = 'owner/repo'; SHA = $sha; File = 'test.yml' } + } + + Should -Invoke Test-GitHubToken -Times 1 + } + } + + Context 'GraphQL batch processing' { + BeforeEach { + $env:GITHUB_TOKEN = 'ghp_test_token' + } + + It 'Returns stale result when SHA differs and age exceeds threshold' { + Mock Test-GitHubToken { return @{ Valid = $true; Authenticated = $true; RateLimit = @{ remaining = 5000 }; Message = '' } } + + $latestSHA = 'bbbb' * 10 + $currentSHA = 'aaaa' * 10 + $oldDate = (Get-Date).AddDays(-60).ToString('o') + $newDate = (Get-Date).ToString('o') + + # Default branch query + Mock Invoke-GitHubAPIWithRetry { + return @{ + data = @{ + rateLimit = @{ remaining = 5000 } + repo0 = @{ defaultBranchRef = @{ target = @{ oid = $latestSHA; committedDate = $newDate } } } + } + } + } -ParameterFilter { $Body -match 'defaultBranchRef' } + + # Commit query — use [PSCustomObject] so PSObject.Properties iteration works + Mock Invoke-GitHubAPIWithRetry { + return [PSCustomObject]@{ + data = [PSCustomObject]@{ + rateLimit = [PSCustomObject]@{ remaining = 5000 } + commit0 = [PSCustomObject]@{ + object = [PSCustomObject]@{ + oid = $currentSHA + committedDate = $oldDate + } + } + } + } + } -ParameterFilter { $Body -match 'commit0' } + + $result = Get-BulkGitHubActionsStaleness -ActionRepos @('actions/checkout') -ShaToActionMap @{ + "actions/checkout@$currentSHA" = @{ Repo = 'actions/checkout'; SHA = $currentSHA; File = 'ci.yml' } + } + + $result | Should -Not -BeNullOrEmpty + @($result).Count | Should -BeGreaterOrEqual 1 + } + } + + Context 'Invalid token' { + BeforeEach { + $env:GITHUB_TOKEN = '' + $env:SYSTEM_ACCESSTOKEN = '' + $env:GH_TOKEN = '' + $env:BUILD_REPOSITORY_PROVIDER = '' + } + + It 'Returns empty when no valid token is available' { + Mock Test-GitHubToken { return @{ Valid = $false; Authenticated = $false; Message = 'No token' } } + Mock Write-SecurityLog { } + Mock Invoke-GitHubAPIWithRetry { + return @{ + data = @{ + rateLimit = @{ remaining = 60 } + repo0 = @{ defaultBranchRef = $null } + } + } + } + + $sha = 'aaaa' * 10 + $result = Get-BulkGitHubActionsStaleness -ActionRepos @('owner/repo') -ShaToActionMap @{ + "owner/repo@$sha" = @{ Repo = 'owner/repo'; SHA = $sha; File = 'test.yml' } + } + + @($result).Count | Should -Be 0 + } + } +} + +Describe 'Test-GitHubActionsForStaleness' -Tag 'Unit' { + BeforeAll { + Save-CIEnvironment + $script:TestWorkflows = Join-Path $TestDrive '.github' 'workflows' + New-Item -ItemType Directory -Path $script:TestWorkflows -Force | Out-Null + } + AfterAll { + Restore-CIEnvironment + } + + Context 'Workflow scanning' { + It 'Returns empty when no SHA-pinned actions found' { + $ymlContent = @' +name: test +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 +'@ + Set-Content (Join-Path $script:TestWorkflows 'no-sha.yml') -Value $ymlContent + + Push-Location $TestDrive + try { + Mock Write-SecurityLog { } + Mock Get-BulkGitHubActionsStaleness { return @() } + + $null = Test-GitHubActionsForStaleness + + # No SHA-pinned actions found = early return + Should -Not -Invoke Get-BulkGitHubActionsStaleness + } + finally { + Pop-Location + } + } + + It 'Detects SHA-pinned actions in workflow files' { + $sha = 'a' * 40 + $ymlContent = @" +name: test +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@$sha +"@ + Set-Content (Join-Path $script:TestWorkflows 'pinned.yml') -Value $ymlContent + + Push-Location $TestDrive + try { + Mock Write-SecurityLog { } + Mock Get-BulkGitHubActionsStaleness { return @() } + + $null = Test-GitHubActionsForStaleness + + Should -Invoke Get-BulkGitHubActionsStaleness -Times 1 + } + finally { + Pop-Location + } + } + } + + Context 'No workflow directory' { + It 'Returns empty when .github/workflows does not exist' { + $emptyDir = Join-Path $TestDrive 'empty-project' + New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null + + Push-Location $emptyDir + try { + Mock Write-SecurityLog { } + + $result = Test-GitHubActionsForStaleness + @($result).Count | Should -Be 0 + } + finally { + Pop-Location + } + } + } +} + +Describe 'Write-OutputResult' -Tag 'Unit' { + Context 'JSON output format' { + It 'Creates output file with correct structure' { + $jsonPath = Join-Path $TestDrive 'output.json' + $deps = @( + @{ Type = 'GitHubAction'; Name = 'actions/checkout'; DaysOld = 45; Severity = 'Low' } + ) + + Write-OutputResult -Dependencies $deps -OutputFormat 'json' -OutputPath $jsonPath + + Test-Path $jsonPath | Should -BeTrue + $content = Get-Content $jsonPath | ConvertFrom-Json + $content.TotalStaleItems | Should -Be 1 + } + } + + Context 'Console output format' { + It 'Writes formatted output via Write-SecurityLog' { + Mock Write-SecurityLog { } + + $deps = @( + @{ Type = 'GitHubAction'; ActionRepo = 'actions/checkout'; DaysOld = 45; Severity = 'Low'; File = 'ci.yml' } + ) + + Write-OutputResult -Dependencies $deps -OutputFormat 'console' + + Should -Invoke Write-SecurityLog -Times 1 + } + } + + Context 'Summary output format' { + It 'Groups dependencies by type' { + Mock Write-Output { } + + $deps = @( + @{ Type = 'GitHubAction'; Name = 'actions/checkout'; DaysOld = 45; Severity = 'Low' } + @{ Type = 'Tool'; Name = 'node'; DaysOld = 90; Severity = 'High' } + ) + + Write-OutputResult -Dependencies $deps -OutputFormat 'Summary' + + Should -Invoke Write-Output -Times 1 + } + } +} + +Describe 'Invoke-SHAStalenessCheck' -Tag 'Unit' { + BeforeAll { + Save-CIEnvironment + } + AfterAll { + Restore-CIEnvironment + } + + Context 'Log directory creation' { + It 'Creates log directory when it does not exist' { + $logPath = Join-Path $TestDrive 'staleness-logs' 'test.log' + $env:GITHUB_TOKEN = 'ghp_test' + + Mock Test-GitHubActionsForStaleness { return @() } + Mock Get-ToolStaleness { } + Mock Write-OutputResult { } + Mock New-Item { } -ParameterFilter { $ItemType -eq 'Directory' } + Mock Write-SecurityLog { } + + Invoke-SHAStalenessCheck -OutputFormat 'console' -LogPath $logPath + + Should -Invoke New-Item -Times 1 + } + } + + Context 'FailOnStale behavior' { + It 'Throws when stale dependencies are detected and FailOnStale is set' { + $env:GITHUB_TOKEN = 'ghp_test' + Mock Write-SecurityLog { } + Mock New-Item { } + Mock Test-GitHubActionsForStaleness { + $script:StaleDependencies = @( + @{ Type = 'GitHubAction'; Name = 'actions/checkout'; DaysOld = 45 } + ) + } + Mock Get-ToolStaleness { } + Mock Write-OutputResult { } + + { Invoke-SHAStalenessCheck -OutputFormat 'console' -FailOnStale } | + Should -Throw '*Stale dependencies detected*' + } + + It 'Does not throw when no stale dependencies and FailOnStale is set' { + $env:GITHUB_TOKEN = 'ghp_test' + Mock Write-SecurityLog { } + Mock New-Item { } + Mock Test-GitHubActionsForStaleness { + $script:StaleDependencies = @() + } + Mock Get-ToolStaleness { } + Mock Write-OutputResult { } + + { Invoke-SHAStalenessCheck -OutputFormat 'console' -FailOnStale } | + Should -Not -Throw + } + } +} diff --git a/scripts/tests/security/Update-ActionSHAPinning.Tests.ps1 b/scripts/tests/security/Update-ActionSHAPinning.Tests.ps1 index b276c91b..45b5f6f4 100644 --- a/scripts/tests/security/Update-ActionSHAPinning.Tests.ps1 +++ b/scripts/tests/security/Update-ActionSHAPinning.Tests.ps1 @@ -4,8 +4,6 @@ BeforeAll { $scriptPath = Join-Path $PSScriptRoot '../../security/Update-ActionSHAPinning.ps1' - $script:OriginalSkipMain = $env:HVE_SKIP_MAIN - $env:HVE_SKIP_MAIN = '1' . $scriptPath $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1' @@ -49,7 +47,6 @@ BeforeAll { AfterAll { Restore-CIEnvironment - $env:HVE_SKIP_MAIN = $script:OriginalSkipMain } Describe 'Get-ActionReference' -Tag 'Unit' { @@ -149,9 +146,9 @@ Describe 'Update-WorkflowFile' -Tag 'Unit' { } Context 'Return value structure' { - It 'Returns hashtable with FilePath' { + It 'Returns PSCustomObject with FilePath' { $result = Update-WorkflowFile -FilePath $script:TestWorkflow - $result | Should -BeOfType [hashtable] + $result | Should -BeOfType [PSCustomObject] $result.FilePath | Should -Be $script:TestWorkflow } @@ -162,7 +159,7 @@ Describe 'Update-WorkflowFile' -Tag 'Unit' { It 'Returns ActionsPinned count' { $result = Update-WorkflowFile -FilePath $script:TestWorkflow - $result.ContainsKey('ActionsPinned') | Should -BeTrue + $result.PSObject.Properties.Name -contains 'ActionsPinned' | Should -BeTrue } } @@ -788,3 +785,223 @@ Describe 'Write-SecurityLog' -Tag 'Unit' { } } } + +Describe 'Get-SHAForAction - Already Pinned' -Tag 'Unit' { + BeforeAll { + $script:OriginalGitHubToken = $env:GITHUB_TOKEN + $env:GITHUB_TOKEN = 'ghp_test123456789' + } + + AfterAll { + $env:GITHUB_TOKEN = $script:OriginalGitHubToken + } + + Context 'SHA-pinned action without UpdateStale' { + It 'Returns original ref when action is already SHA-pinned' { + $sha = 'a' * 40 + $ref = "actions/checkout@$sha" + Mock Write-SecurityLog { } + + $result = Get-SHAForAction -ActionRef $ref + + $result | Should -Be $ref + } + } + + Context 'SHA-pinned action with UpdateStale' { + It 'Returns original ref when UpdateStale is not specified' { + $currentSHA = 'a' * 40 + $latestSHA = 'b' * 40 + $ref = "actions/checkout@$currentSHA" + + Mock Write-SecurityLog { } + Mock Get-LatestCommitSHA { return $latestSHA } + + $result = Get-SHAForAction -ActionRef $ref + + # Without UpdateStale flag in scope, returns original + $result | Should -Be $ref + } + } +} + +Describe 'Update-WorkflowFile - Edge Cases' -Tag 'Unit' { + Context 'No actions in file' { + It 'Returns zero counts when file has no action references' { + $testFile = Join-Path $TestDrive 'empty-workflow.yml' + Set-Content $testFile -Value @' +name: empty +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hello +'@ + Mock Write-SecurityLog { } + + $result = Update-WorkflowFile -FilePath $testFile + + $result.ActionsProcessed | Should -Be 0 + $result.ActionsPinned | Should -Be 0 + $result.ActionsSkipped | Should -Be 0 + } + } + + Context 'File with local actions' { + It 'Skips local action references starting with ./' { + $testFile = Join-Path $TestDrive 'local-action.yml' + Set-Content $testFile -Value @' +name: local +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: ./local-action +'@ + Mock Write-SecurityLog { } + + $result = Update-WorkflowFile -FilePath $testFile + + $result.ActionsProcessed | Should -Be 0 + } + } +} + +Describe 'Invoke-ActionSHAPinningUpdate' -Tag 'Unit' { + BeforeAll { + $env:GITHUB_TOKEN = 'ghp_test123456789' + Initialize-MockCIEnvironment + } + AfterAll { + Clear-MockCIEnvironment + } + + Context 'Missing workflow path' { + It 'Throws when workflow path does not exist' { + { Invoke-ActionSHAPinningUpdate -WorkflowPath '/nonexistent/path' } | + Should -Throw '*Workflow path not found*' + } + } + + Context 'No YAML files in directory' { + It 'Warns and returns when no yml files found' { + $emptyDir = Join-Path $TestDrive 'empty-workflows' + New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null + + Mock Write-SecurityLog { } + + Invoke-ActionSHAPinningUpdate -WorkflowPath $emptyDir + + Should -Invoke Write-SecurityLog -Times 1 -ParameterFilter { $Level -eq 'Warning' } + } + } + + Context 'Full orchestration' { + It 'Processes workflow files and generates summary' { + $workDir = Join-Path $TestDrive 'orchestration-workflows' + New-Item -ItemType Directory -Path $workDir -Force | Out-Null + + $sha = 'a' * 40 + $content = @" +name: test +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@$sha +"@ + Set-Content (Join-Path $workDir 'ci.yml') -Value $content + + Mock Write-SecurityLog { } + Mock Write-OutputResult { } + Mock Get-SHAForAction { return "actions/checkout@$sha" } + + Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputFormat 'console' + + Should -Invoke Write-OutputResult -Times 1 + } + } + + Context 'OutputReport flag' { + It 'Calls Export-SecurityReport when OutputReport is set' { + $workDir = Join-Path $TestDrive 'report-workflows' + New-Item -ItemType Directory -Path $workDir -Force | Out-Null + + Set-Content (Join-Path $workDir 'test.yml') -Value @' +name: test +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi +'@ + Mock Write-SecurityLog { } + Mock Write-OutputResult { } + Mock Export-SecurityReport { return (Join-Path $TestDrive 'report.json') } + + Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputReport -OutputFormat 'console' + + Should -Invoke Export-SecurityReport -Times 1 + } + } + + Context 'Manual review actions' { + It 'Adds SecurityIssue for actions requiring manual review' { + $workDir = Join-Path $TestDrive 'manual-review-workflows' + New-Item -ItemType Directory -Path $workDir -Force | Out-Null + + Set-Content (Join-Path $workDir 'unmapped.yml') -Value @' +name: unmapped +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Unknown action + uses: some-unknown/action@v1 +'@ + Mock Write-SecurityLog { } + Mock Write-OutputResult { } + Mock Get-SHAForAction { return $null } + Mock Add-SecurityIssue { } + + Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputFormat 'console' + + Should -Invoke Add-SecurityIssue -Times 1 + } + } + + Context 'WhatIf support' { + It 'Does not modify files when WhatIf is used' { + $workDir = Join-Path $TestDrive 'whatif-workflows' + New-Item -ItemType Directory -Path $workDir -Force | Out-Null + + $sha = 'a' * 40 + $content = @" +name: whatif +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@$sha +"@ + $filePath = Join-Path $workDir 'whatif.yml' + Set-Content $filePath -Value $content + + Mock Write-SecurityLog { } + Mock Write-OutputResult { } + Mock Get-SHAForAction { return "actions/checkout@$sha" } + + Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputFormat 'console' -WhatIf + + # File content should remain unchanged + $afterContent = Get-Content $filePath -Raw + $afterContent | Should -Match "actions/checkout@$sha" + } + } +}