microsoft/hve-core
Publicmirrored from https://github.com/microsoft/hve-coreAvailable
scripts/tests/security/Sign-PlannerArtifacts.Tests.ps1
378lines · modecode
| 1 | #Requires -Modules Pester |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | |
| 5 | BeforeAll { |
| 6 | # Stub cosign when not installed so Pester can mock it |
| 7 | if (-not (Get-Command cosign -ErrorAction SilentlyContinue)) { function global:cosign { } } |
| 8 | |
| 9 | $script:ScriptPath = Join-Path $PSScriptRoot '../../security/Sign-PlannerArtifacts.ps1' |
| 10 | |
| 11 | # Extract helper functions via AST. The script has a mandatory ProjectSlug |
| 12 | # parameter with script-scope execution, preventing dot-source. |
| 13 | $ast = [System.Management.Automation.Language.Parser]::ParseFile( |
| 14 | $script:ScriptPath, [ref]$null, [ref]$null) |
| 15 | $ast.FindAll( |
| 16 | { $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true |
| 17 | ) | ForEach-Object { . ([scriptblock]::Create($_.Extent.Text)) } |
| 18 | |
| 19 | Mock Write-Host {} |
| 20 | } |
| 21 | |
| 22 | Describe 'Get-ArtifactHash' -Tag 'Unit' { |
| 23 | Context 'SHA-256 computation' { |
| 24 | It 'Returns a lowercase hex SHA-256 hash for a file' { |
| 25 | $testFile = Join-Path $TestDrive 'hash-test.txt' |
| 26 | Set-Content -Path $testFile -Value 'deterministic content' -NoNewline -Encoding utf8NoBOM |
| 27 | |
| 28 | $hash = Get-ArtifactHash -FilePath $testFile |
| 29 | |
| 30 | $hash | Should -Match '^[0-9a-f]{64}$' |
| 31 | } |
| 32 | |
| 33 | It 'Returns consistent hashes for identical content' { |
| 34 | $file1 = Join-Path $TestDrive 'dup1.txt' |
| 35 | $file2 = Join-Path $TestDrive 'dup2.txt' |
| 36 | Set-Content -Path $file1 -Value 'same' -NoNewline -Encoding utf8NoBOM |
| 37 | Set-Content -Path $file2 -Value 'same' -NoNewline -Encoding utf8NoBOM |
| 38 | |
| 39 | $hash1 = Get-ArtifactHash -FilePath $file1 |
| 40 | $hash2 = Get-ArtifactHash -FilePath $file2 |
| 41 | |
| 42 | $hash1 | Should -Be $hash2 |
| 43 | } |
| 44 | |
| 45 | It 'Returns different hashes for different content' { |
| 46 | $file1 = Join-Path $TestDrive 'diff1.txt' |
| 47 | $file2 = Join-Path $TestDrive 'diff2.txt' |
| 48 | Set-Content -Path $file1 -Value 'alpha' -NoNewline -Encoding utf8NoBOM |
| 49 | Set-Content -Path $file2 -Value 'bravo' -NoNewline -Encoding utf8NoBOM |
| 50 | |
| 51 | $hash1 = Get-ArtifactHash -FilePath $file1 |
| 52 | $hash2 = Get-ArtifactHash -FilePath $file2 |
| 53 | |
| 54 | $hash1 | Should -Not -Be $hash2 |
| 55 | } |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | Describe 'Manifest Generation' -Tag 'Unit' { |
| 60 | BeforeEach { |
| 61 | $script:projectSlug = 'test-project' |
| 62 | $script:artifactDir = Join-Path $TestDrive ".copilot-tracking/rai-plans/$($script:projectSlug)" |
| 63 | if (Test-Path $script:artifactDir) { Remove-Item $script:artifactDir -Recurse -Force } |
| 64 | New-Item -ItemType Directory -Path $script:artifactDir -Force | Out-Null |
| 65 | } |
| 66 | |
| 67 | Context 'when artifact directory does not exist' { |
| 68 | It 'Exits with code 1 for missing directory' { |
| 69 | $missingSlug = 'nonexistent-project' |
| 70 | $originalPWD = $PWD |
| 71 | try { |
| 72 | Set-Location $TestDrive |
| 73 | & $script:ScriptPath -ProjectSlug $missingSlug |
| 74 | } |
| 75 | catch { $_ | Out-Null } |
| 76 | finally { |
| 77 | Set-Location $originalPWD |
| 78 | } |
| 79 | $LASTEXITCODE | Should -Be 1 |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | Context 'when artifact directory is empty' { |
| 84 | It 'Exits with code 0 and reports no artifacts' { |
| 85 | $originalPWD = $PWD |
| 86 | try { |
| 87 | Set-Location $TestDrive |
| 88 | & $script:ScriptPath -ProjectSlug $script:projectSlug |
| 89 | } |
| 90 | catch { $_ | Out-Null } |
| 91 | finally { |
| 92 | Set-Location $originalPWD |
| 93 | } |
| 94 | |
| 95 | Should -Invoke Write-Host -ParameterFilter { $Object -like '*No artifacts found*' } |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | Context 'when artifacts exist' { |
| 100 | BeforeEach { |
| 101 | Set-Content -Path (Join-Path $script:artifactDir 'state.json') -Value '{"status":"complete"}' -Encoding utf8NoBOM |
| 102 | Set-Content -Path (Join-Path $script:artifactDir 'findings.md') -Value '# Findings' -Encoding utf8NoBOM |
| 103 | $script:outputPath = Join-Path $script:artifactDir 'artifact-manifest.json' |
| 104 | } |
| 105 | |
| 106 | It 'Generates a valid JSON manifest' { |
| 107 | $originalPWD = $PWD |
| 108 | try { |
| 109 | Set-Location $TestDrive |
| 110 | & $script:ScriptPath -ProjectSlug $script:projectSlug |
| 111 | } |
| 112 | finally { |
| 113 | Set-Location $originalPWD |
| 114 | } |
| 115 | |
| 116 | Test-Path $script:outputPath | Should -BeTrue |
| 117 | $manifest = Get-Content $script:outputPath -Raw | ConvertFrom-Json |
| 118 | $manifest.version | Should -Be '1.0' |
| 119 | $manifest.projectSlug | Should -Be $script:projectSlug |
| 120 | $manifest.algorithm | Should -Be 'SHA256' |
| 121 | } |
| 122 | |
| 123 | It 'Includes correct file count' { |
| 124 | $originalPWD = $PWD |
| 125 | try { |
| 126 | Set-Location $TestDrive |
| 127 | & $script:ScriptPath -ProjectSlug $script:projectSlug |
| 128 | } |
| 129 | finally { |
| 130 | Set-Location $originalPWD |
| 131 | } |
| 132 | |
| 133 | $manifest = Get-Content $script:outputPath -Raw | ConvertFrom-Json |
| 134 | $manifest.fileCount | Should -Be 2 |
| 135 | $manifest.artifacts.Count | Should -Be 2 |
| 136 | } |
| 137 | |
| 138 | It 'Computes SHA-256 hashes for each artifact' { |
| 139 | $originalPWD = $PWD |
| 140 | try { |
| 141 | Set-Location $TestDrive |
| 142 | & $script:ScriptPath -ProjectSlug $script:projectSlug |
| 143 | } |
| 144 | finally { |
| 145 | Set-Location $originalPWD |
| 146 | } |
| 147 | |
| 148 | $manifest = Get-Content $script:outputPath -Raw | ConvertFrom-Json |
| 149 | foreach ($artifact in $manifest.artifacts) { |
| 150 | $artifact.sha256 | Should -Match '^[0-9a-f]{64}$' |
| 151 | $artifact.path | Should -Not -BeNullOrEmpty |
| 152 | $artifact.sizeBytes | Should -BeGreaterThan 0 |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | It 'Writes manifest to custom OutputPath when specified' { |
| 157 | $customPath = Join-Path $TestDrive 'custom-manifest.json' |
| 158 | |
| 159 | $originalPWD = $PWD |
| 160 | try { |
| 161 | Set-Location $TestDrive |
| 162 | & $script:ScriptPath -ProjectSlug $script:projectSlug -OutputPath $customPath |
| 163 | } |
| 164 | finally { |
| 165 | Set-Location $originalPWD |
| 166 | } |
| 167 | |
| 168 | Test-Path $customPath | Should -BeTrue |
| 169 | $manifest = Get-Content $customPath -Raw | ConvertFrom-Json |
| 170 | $manifest.fileCount | Should -Be 2 |
| 171 | } |
| 172 | |
| 173 | It 'Includes generatedAt in ISO 8601 format' { |
| 174 | $originalPWD = $PWD |
| 175 | try { |
| 176 | Set-Location $TestDrive |
| 177 | & $script:ScriptPath -ProjectSlug $script:projectSlug |
| 178 | } |
| 179 | finally { |
| 180 | Set-Location $originalPWD |
| 181 | } |
| 182 | |
| 183 | $raw = Get-Content $script:outputPath -Raw |
| 184 | $raw | Should -Match '"generatedAt"\s*:\s*"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}' |
| 185 | } |
| 186 | |
| 187 | It 'Orders artifacts alphabetically by path' { |
| 188 | $originalPWD = $PWD |
| 189 | try { |
| 190 | Set-Location $TestDrive |
| 191 | & $script:ScriptPath -ProjectSlug $script:projectSlug |
| 192 | } |
| 193 | finally { |
| 194 | Set-Location $originalPWD |
| 195 | } |
| 196 | |
| 197 | $manifest = Get-Content $script:outputPath -Raw | ConvertFrom-Json |
| 198 | $paths = $manifest.artifacts | ForEach-Object { $_.path } |
| 199 | $sorted = $paths | Sort-Object |
| 200 | $paths | Should -Be $sorted |
| 201 | } |
| 202 | |
| 203 | It 'Contains all required top-level manifest fields' { |
| 204 | $originalPWD = $PWD |
| 205 | try { |
| 206 | Set-Location $TestDrive |
| 207 | & $script:ScriptPath -ProjectSlug $script:projectSlug |
| 208 | } |
| 209 | finally { |
| 210 | Set-Location $originalPWD |
| 211 | } |
| 212 | |
| 213 | $manifest = Get-Content $script:outputPath -Raw | ConvertFrom-Json |
| 214 | $fields = ($manifest | Get-Member -MemberType NoteProperty).Name | Sort-Object |
| 215 | $expected = @('algorithm', 'artifacts', 'fileCount', 'generatedAt', 'projectSlug', 'sessionPath', 'version') | Sort-Object |
| 216 | $fields | Should -Be $expected |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | Context 'when SessionPath is specified' { |
| 221 | It 'Generates a manifest for a repo-relative session path' { |
| 222 | $relativeSessionPath = '.copilot-tracking/sssc-plans/test-sssc-session' |
| 223 | $sessionDir = Join-Path $TestDrive $relativeSessionPath |
| 224 | New-Item -ItemType Directory -Path $sessionDir -Force | Out-Null |
| 225 | Set-Content -Path (Join-Path $sessionDir 'state.json') -Value '{}' -Encoding utf8NoBOM |
| 226 | |
| 227 | $originalPWD = $PWD |
| 228 | try { |
| 229 | Set-Location $TestDrive |
| 230 | & $script:ScriptPath -SessionPath $relativeSessionPath |
| 231 | } |
| 232 | finally { |
| 233 | Set-Location $originalPWD |
| 234 | } |
| 235 | |
| 236 | $outputPath = Join-Path $sessionDir 'artifact-manifest.json' |
| 237 | Test-Path $outputPath | Should -BeTrue |
| 238 | $manifest = Get-Content $outputPath -Raw | ConvertFrom-Json |
| 239 | $manifest.projectSlug | Should -Be 'test-sssc-session' |
| 240 | $manifest.sessionPath | Should -Be '.copilot-tracking/sssc-plans/test-sssc-session' |
| 241 | $manifest.fileCount | Should -Be 1 |
| 242 | } |
| 243 | |
| 244 | It 'Generates a manifest for an absolute session path' { |
| 245 | $sessionDir = Join-Path $TestDrive '.copilot-tracking/sssc-plans/absolute-session' |
| 246 | New-Item -ItemType Directory -Path $sessionDir -Force | Out-Null |
| 247 | Set-Content -Path (Join-Path $sessionDir 'state.json') -Value '{}' -Encoding utf8NoBOM |
| 248 | |
| 249 | $originalPWD = $PWD |
| 250 | try { |
| 251 | Set-Location (Split-Path -Path $script:ScriptPath -Parent) |
| 252 | & $script:ScriptPath -SessionPath $sessionDir |
| 253 | } |
| 254 | finally { |
| 255 | Set-Location $originalPWD |
| 256 | } |
| 257 | |
| 258 | $outputPath = Join-Path $sessionDir 'artifact-manifest.json' |
| 259 | Test-Path $outputPath | Should -BeTrue |
| 260 | $manifest = Get-Content $outputPath -Raw | ConvertFrom-Json |
| 261 | $manifest.projectSlug | Should -Be 'absolute-session' |
| 262 | $manifest.sessionPath | Should -Be ($sessionDir -replace '\\','/') |
| 263 | $manifest.fileCount | Should -Be 1 |
| 264 | } |
| 265 | |
| 266 | It 'Uses a custom manifest name and excludes it from artifact inventory' { |
| 267 | $relativeSessionPath = '.copilot-tracking/sssc-plans/custom-manifest-session' |
| 268 | $sessionDir = Join-Path $TestDrive $relativeSessionPath |
| 269 | New-Item -ItemType Directory -Path $sessionDir -Force | Out-Null |
| 270 | Set-Content -Path (Join-Path $sessionDir 'state.json') -Value '{}' -Encoding utf8NoBOM |
| 271 | Set-Content -Path (Join-Path $sessionDir 'sssc-manifest.json') -Value '{}' -Encoding utf8NoBOM |
| 272 | |
| 273 | $originalPWD = $PWD |
| 274 | try { |
| 275 | Set-Location $TestDrive |
| 276 | & $script:ScriptPath -SessionPath $relativeSessionPath -ManifestName 'sssc-manifest.json' |
| 277 | } |
| 278 | finally { |
| 279 | Set-Location $originalPWD |
| 280 | } |
| 281 | |
| 282 | $outputPath = Join-Path $sessionDir 'sssc-manifest.json' |
| 283 | Test-Path $outputPath | Should -BeTrue |
| 284 | $manifest = Get-Content $outputPath -Raw | ConvertFrom-Json |
| 285 | $manifest.fileCount | Should -Be 1 |
| 286 | $manifest.artifacts[0].path | Should -Be 'state.json' |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | Context 'exclude patterns' { |
| 291 | It 'Excludes artifact-manifest.json from the inventory' { |
| 292 | Set-Content -Path (Join-Path $script:artifactDir 'state.json') -Value '{}' -Encoding utf8NoBOM |
| 293 | Set-Content -Path (Join-Path $script:artifactDir 'artifact-manifest.json') -Value '{}' -Encoding utf8NoBOM |
| 294 | |
| 295 | $outputPath = Join-Path $script:artifactDir 'artifact-manifest.json' |
| 296 | $originalPWD = $PWD |
| 297 | try { |
| 298 | Set-Location $TestDrive |
| 299 | & $script:ScriptPath -ProjectSlug $script:projectSlug |
| 300 | } |
| 301 | finally { |
| 302 | Set-Location $originalPWD |
| 303 | } |
| 304 | |
| 305 | $manifest = Get-Content $outputPath -Raw | ConvertFrom-Json |
| 306 | $manifest.fileCount | Should -Be 1 |
| 307 | $manifest.artifacts[0].path | Should -Be 'state.json' |
| 308 | } |
| 309 | |
| 310 | It 'Excludes .sig and .bundle files from the inventory' { |
| 311 | Set-Content -Path (Join-Path $script:artifactDir 'data.md') -Value '# Data' -Encoding utf8NoBOM |
| 312 | Set-Content -Path (Join-Path $script:artifactDir 'manifest.json.sig') -Value 'sig' -Encoding utf8NoBOM |
| 313 | Set-Content -Path (Join-Path $script:artifactDir 'manifest.json.bundle') -Value 'bundle' -Encoding utf8NoBOM |
| 314 | |
| 315 | $originalPWD = $PWD |
| 316 | try { |
| 317 | Set-Location $TestDrive |
| 318 | & $script:ScriptPath -ProjectSlug $script:projectSlug |
| 319 | } |
| 320 | finally { |
| 321 | Set-Location $originalPWD |
| 322 | } |
| 323 | |
| 324 | $manifest = Get-Content $script:outputPath -Raw | ConvertFrom-Json |
| 325 | $manifest.fileCount | Should -Be 1 |
| 326 | $manifest.artifacts[0].path | Should -Be 'data.md' |
| 327 | } |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | Describe 'Cosign Signing' -Tag 'Unit' { |
| 332 | BeforeEach { |
| 333 | $script:projectSlug = 'cosign-test' |
| 334 | $script:artifactDir = Join-Path $TestDrive ".copilot-tracking/rai-plans/$($script:projectSlug)" |
| 335 | if (Test-Path $script:artifactDir) { Remove-Item $script:artifactDir -Recurse -Force } |
| 336 | New-Item -ItemType Directory -Path $script:artifactDir -Force | Out-Null |
| 337 | Set-Content -Path (Join-Path $script:artifactDir 'data.md') -Value '# Test' -Encoding utf8NoBOM |
| 338 | $script:outputPath = Join-Path $script:artifactDir 'artifact-manifest.json' |
| 339 | } |
| 340 | |
| 341 | Context 'when cosign is not installed' { |
| 342 | It 'Warns and skips signing gracefully' { |
| 343 | Mock Get-Command { $null } -ParameterFilter { $Name -eq 'cosign' } |
| 344 | Mock cosign {} |
| 345 | |
| 346 | $originalPWD = $PWD |
| 347 | try { |
| 348 | Set-Location $TestDrive |
| 349 | & $script:ScriptPath -ProjectSlug $script:projectSlug -IncludeCosign |
| 350 | } |
| 351 | catch { $_ | Out-Null } |
| 352 | finally { |
| 353 | Set-Location $originalPWD |
| 354 | } |
| 355 | |
| 356 | Should -Invoke Write-Host -ParameterFilter { $Object -like '*cosign not found*' } |
| 357 | Should -Not -Invoke cosign |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | Context 'when cosign is available' { |
| 362 | It 'Invokes cosign sign-blob with correct arguments' { |
| 363 | Mock Get-Command { [pscustomobject]@{ Name = 'cosign' } } -ParameterFilter { $Name -eq 'cosign' } |
| 364 | Mock cosign {} |
| 365 | |
| 366 | $originalPWD = $PWD |
| 367 | try { |
| 368 | Set-Location $TestDrive |
| 369 | & $script:ScriptPath -ProjectSlug $script:projectSlug -IncludeCosign |
| 370 | } |
| 371 | finally { |
| 372 | Set-Location $originalPWD |
| 373 | } |
| 374 | |
| 375 | Should -Invoke cosign -Times 1 -Exactly |
| 376 | } |
| 377 | } |
| 378 | } |
| 379 | |