microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/add-second-skill-package

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

scripts/tests/security/Test-SHAStaleness.Tests.ps1

1159lines · modecode

1#Requires -Modules Pester
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4
5<#
6.SYNOPSIS
7 Pester tests for Test-SHAStaleness.ps1 functions.
8
9.DESCRIPTION
10 Tests the staleness checking functions without executing the main script.
11 Uses dot-source guard pattern for function isolation.
12#>
13
14BeforeAll {
15 $scriptPath = Join-Path $PSScriptRoot '../../security/Test-SHAStaleness.ps1'
16 . $scriptPath
17 # Re-import CIHelpers so Pester can resolve its commands for mocking;
18 # the nested-module import inside SecurityHelpers shadows the standalone copy.
19 Import-Module (Join-Path $PSScriptRoot '../../lib/Modules/CIHelpers.psm1') -Force
20
21 $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1'
22 Import-Module $mockPath -Force
23
24 # Save environment before tests
25 Save-CIEnvironment
26
27 # Fixture paths
28 $script:FixturesPath = Join-Path $PSScriptRoot '../Fixtures/Security'
29}
30
31AfterAll {
32 # Restore environment after tests
33 Restore-CIEnvironment
34}
35
36Describe 'Test-GitHubToken' -Tag 'Unit' {
37 BeforeEach {
38 Initialize-MockCIEnvironment
39 }
40
41 AfterEach {
42 Clear-MockCIEnvironment
43 }
44
45 Context 'No token provided' {
46 It 'Returns hashtable with Valid=false when empty token provided' {
47 $result = Test-GitHubToken -Token ''
48 $result | Should -BeOfType [hashtable]
49 $result.Valid | Should -BeFalse
50 }
51
52 It 'Returns Authenticated=false when no token provided' {
53 $result = Test-GitHubToken -Token ''
54 $result.Authenticated | Should -BeFalse
55 }
56
57 It 'Returns rate limit of 0 when no token provided' {
58 $result = Test-GitHubToken -Token ''
59 $result.RateLimit | Should -Be 0
60 }
61 }
62
63 Context 'Invalid token' {
64 BeforeEach {
65 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
66 throw 'Bad credentials'
67 }
68 }
69
70 It 'Returns Valid=false for invalid token' {
71 $result = Test-GitHubToken -Token 'invalid-token'
72 $result.Valid | Should -BeFalse
73 }
74 }
75
76 Context 'Valid token' {
77 BeforeEach {
78 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
79 return @{
80 data = @{
81 viewer = @{ login = 'testuser' }
82 rateLimit = @{ limit = 5000; remaining = 4999; resetAt = '2024-01-01T00:00:00Z' }
83 }
84 }
85 }
86 }
87
88 It 'Returns Valid=true for valid token' {
89 $result = Test-GitHubToken -Token 'ghp_validtoken123456789'
90 $result.Valid | Should -BeTrue
91 }
92
93 It 'Returns user information for valid token' {
94 $result = Test-GitHubToken -Token 'ghp_validtoken123456789'
95 $result.User | Should -Be 'testuser'
96 }
97
98 It 'Returns rate limit information for valid token' {
99 $result = Test-GitHubToken -Token 'ghp_validtoken123456789'
100 $result.RateLimit | Should -Be 5000
101 $result.Remaining | Should -Be 4999
102 }
103 }
104}
105
106Describe 'Invoke-GitHubAPIWithRetry' -Tag 'Unit' {
107 BeforeEach {
108 Initialize-MockCIEnvironment
109 }
110
111 AfterEach {
112 Clear-MockCIEnvironment
113 }
114
115 Context 'Successful requests' {
116 It 'Returns response on first successful call' {
117 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
118 return @{ data = 'success' }
119 }
120
121 $headers = @{ 'Authorization' = 'Bearer test' }
122 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/graphql' -Method 'POST' -Headers $headers -Body '{}'
123 $result.data | Should -Be 'success'
124 }
125 }
126
127 Context 'Non-retryable errors' {
128 It 'Returns null on non-rate-limit errors' {
129 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
130 throw [System.Exception]::new('Network error')
131 }
132
133 $headers = @{ 'Authorization' = 'Bearer test' }
134 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/graphql' -Method 'POST' -Headers $headers -Body '{}'
135 $result | Should -BeNullOrEmpty
136 }
137 }
138}
139
140Describe 'Compare-ToolVersion' -Tag 'Unit' {
141 Context 'Semantic version comparison' {
142 It 'Returns true when latest is newer major version' {
143 Compare-ToolVersion -Current '1.0.0' -Latest '2.0.0' | Should -BeTrue
144 }
145
146 It 'Returns true when latest is newer minor version' {
147 Compare-ToolVersion -Current '1.0.0' -Latest '1.1.0' | Should -BeTrue
148 }
149
150 It 'Returns true when latest is newer patch version' {
151 Compare-ToolVersion -Current '1.0.0' -Latest '1.0.1' | Should -BeTrue
152 }
153
154 It 'Returns false when current equals latest' {
155 Compare-ToolVersion -Current '1.0.0' -Latest '1.0.0' | Should -BeFalse
156 }
157
158 It 'Returns false when current is newer than latest' {
159 Compare-ToolVersion -Current '2.0.0' -Latest '1.0.0' | Should -BeFalse
160 }
161
162 It 'Handles major version differences correctly' {
163 Compare-ToolVersion -Current '7.0.0' -Latest '8.0.0' | Should -BeTrue
164 }
165
166 It 'Handles minor version differences correctly' {
167 Compare-ToolVersion -Current '8.17.0' -Latest '8.18.0' | Should -BeTrue
168 }
169
170 It 'Handles patch version differences correctly' {
171 Compare-ToolVersion -Current '8.18.1' -Latest '8.18.2' | Should -BeTrue
172 }
173 }
174
175 Context 'Version with v prefix' {
176 It 'Handles v-prefixed versions' {
177 Compare-ToolVersion -Current 'v1.0.0' -Latest 'v2.0.0' | Should -BeTrue
178 }
179
180 It 'Handles mixed v-prefix versions' {
181 Compare-ToolVersion -Current '1.0.0' -Latest 'v2.0.0' | Should -BeTrue
182 }
183
184 It 'Returns false for equal v-prefixed versions' {
185 Compare-ToolVersion -Current 'v1.0.0' -Latest 'v1.0.0' | Should -BeFalse
186 }
187 }
188
189 Context 'Pre-release versions' {
190 It 'Strips pre-release metadata for comparison' {
191 Compare-ToolVersion -Current '1.0.0-alpha' -Latest '1.0.0' | Should -BeFalse
192 }
193
194 It 'Handles build metadata' {
195 Compare-ToolVersion -Current '1.0.0+build123' -Latest '2.0.0' | Should -BeTrue
196 }
197 }
198}
199
200Describe 'Get-ToolStaleness' -Tag 'Integration', 'RequiresNetwork' {
201 Context 'With mock manifest' {
202 BeforeEach {
203 # Create a temporary manifest file
204 $script:TempManifest = Join-Path $TestDrive 'tool-checksums.json'
205 $manifestContent = @{
206 tools = @(
207 @{
208 name = 'test-tool'
209 repo = 'test-org/test-repo'
210 version = '1.0.0'
211 sha256 = 'abc123'
212 notes = 'Test tool'
213 }
214 )
215 } | ConvertTo-Json -Depth 10
216 Set-Content -Path $script:TempManifest -Value $manifestContent
217 }
218
219 It 'Returns results array' -Skip:$true {
220 # Skip by default - requires actual GitHub API access
221 $result = Get-ToolStaleness -ManifestPath $script:TempManifest
222 $result | Should -BeOfType [System.Object[]]
223 }
224 }
225
226 Context 'Missing manifest' {
227 It 'Handles missing manifest gracefully' {
228 $result = Get-ToolStaleness -ManifestPath 'TestDrive:/nonexistent/manifest.json'
229 $result | Should -BeNullOrEmpty
230 }
231 }
232}
233
234Describe 'Main Script Execution' {
235 BeforeAll {
236 # Create test repo structure (script expects .github/workflows from current directory)
237 $script:TestRepo = Join-Path $TestDrive 'test-repo'
238 $script:WorkflowDir = Join-Path $script:TestRepo '.github' 'workflows'
239 New-Item -ItemType Directory -Path $script:WorkflowDir -Force | Out-Null
240
241 # Create logs directory
242 $logsDir = Join-Path $script:TestRepo 'logs'
243 New-Item -ItemType Directory -Path $logsDir -Force | Out-Null
244
245 # Create test manifest in scripts/security location
246 $manifestDir = Join-Path $script:TestRepo 'scripts' 'security'
247 New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
248 $script:ManifestPath = Join-Path $manifestDir 'tool-checksums.json'
249 @{
250 tools = @(
251 @{
252 name = 'pwsh'
253 repo = 'PowerShell/PowerShell'
254 version = '7.4.0'
255 sha256 = 'test-sha'
256 notes = 'PowerShell'
257 }
258 )
259 } | ConvertTo-Json -Depth 10 | Set-Content -Path $script:ManifestPath
260
261 # Save current directory
262 $script:OriginalLocation = Get-Location
263
264 # Script-scope safety-net mock -- no direct Invoke-RestMethod calls remain in Test-SHAStaleness.ps1
265 # after consolidation; all REST calls route through SecurityHelpers via Invoke-GitHubAPIWithRetry.
266 Mock Invoke-RestMethod {
267 if ($Uri -like '*/releases/latest') {
268 $repoName = ($Uri -split '/')[-3]
269 return @{
270 tag_name = switch ($repoName) {
271 'actionlint' { 'v1.7.10' }
272 'gitleaks' { 'v8.30.0' }
273 default { 'v1.0.0' }
274 }
275 published_at = (Get-Date).AddMonths(-1).ToString('o')
276 }
277 }
278 elseif ($Uri -like '*/repos/*/branches/*') {
279 return @{ commit = @{ sha = '9999999999999999999999999999999999999999' } }
280 }
281 elseif ($Uri -like '*/repos/*/commits/*') {
282 return @{ commit = @{ author = @{ date = (Get-Date).AddDays(-60).ToString('o') } } }
283 }
284 elseif ($Uri -like '*/repos/*') {
285 return @{ default_branch = 'main' }
286 }
287 return @{}
288 }
289
290 # Module-scope mock intercepts Invoke-RestMethod calls made inside SecurityHelpers
291 # (Test-GitHubToken and Invoke-GitHubAPIWithRetry both call Invoke-RestMethod internally).
292 # Uses aliased GraphQL response structure matching the script's batch queries.
293 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
294 if ($Uri -like '*graphql*') {
295 if ($Body -match 'viewer') {
296 # Test-GitHubToken validation query
297 return @{
298 data = @{
299 viewer = @{ login = 'testuser' }
300 rateLimit = @{ remaining = 5000; limit = 5000; resetAt = (Get-Date).AddHours(1).ToString('o') }
301 }
302 }
303 }
304 elseif ($Body -match 'defaultBranchRef') {
305 # Repo batch query -- aliased structure (repo0, repo1, ...)
306 return @{
307 data = @{
308 rateLimit = @{ remaining = 5000; limit = 5000; used = 1; resetAt = (Get-Date).AddHours(1).ToString('o') }
309 repo0 = @{ name = 'checkout'; defaultBranchRef = @{ target = @{ oid = '9999999999999999999999999999999999999999'; committedDate = (Get-Date).ToString('o') } } }
310 repo1 = @{ name = 'setup-node'; defaultBranchRef = @{ target = @{ oid = '8888888888888888888888888888888888888888'; committedDate = (Get-Date).ToString('o') } } }
311 }
312 }
313 }
314 else {
315 # Commit batch query -- PSCustomObject required for PSObject.Properties iteration
316 return [PSCustomObject]@{
317 data = [PSCustomObject]@{
318 rateLimit = [PSCustomObject]@{ remaining = 5000; cost = 1 }
319 commit0 = [PSCustomObject]@{ object = [PSCustomObject]@{ oid = '8e5e7e5ab8b370d6c329ec480221332ada57f0ab'; committedDate = (Get-Date).AddDays(-60).ToString('o') } }
320 commit1 = [PSCustomObject]@{ object = [PSCustomObject]@{ oid = '64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c'; committedDate = (Get-Date).AddDays(-45).ToString('o') } }
321 }
322 }
323 }
324 }
325 elseif ($Uri -like '*/releases/latest') {
326 $repoName = ($Uri -split '/')[-3]
327 return @{
328 tag_name = switch ($repoName) {
329 'actionlint' { 'v1.7.10' }
330 'gitleaks' { 'v8.30.0' }
331 default { 'v1.0.0' }
332 }
333 published_at = (Get-Date).AddMonths(-1).ToString('o')
334 }
335 }
336 return @{}
337 }
338
339 # Script-scope mock for Invoke-GitHubAPIWithRetry -- prevents real HTTP calls
340 # when Import-Module -Force in the production script resets module-scope mocks.
341 Mock Invoke-GitHubAPIWithRetry {
342 if ($Uri -like '*graphql*') {
343 if ($Body -match 'viewer') {
344 return @{
345 data = @{
346 viewer = @{ login = 'testuser' }
347 rateLimit = @{ remaining = 5000; limit = 5000; resetAt = (Get-Date).AddHours(1).ToString('o') }
348 }
349 }
350 }
351 elseif ($Body -match 'defaultBranchRef') {
352 return @{
353 data = @{
354 rateLimit = @{ remaining = 5000; limit = 5000; used = 1; resetAt = (Get-Date).AddHours(1).ToString('o') }
355 repo0 = @{ name = 'checkout'; defaultBranchRef = @{ target = @{ oid = '9999999999999999999999999999999999999999'; committedDate = (Get-Date).ToString('o') } } }
356 repo1 = @{ name = 'setup-node'; defaultBranchRef = @{ target = @{ oid = '8888888888888888888888888888888888888888'; committedDate = (Get-Date).ToString('o') } } }
357 }
358 }
359 }
360 else {
361 return [PSCustomObject]@{
362 data = [PSCustomObject]@{
363 rateLimit = [PSCustomObject]@{ remaining = 5000; cost = 1 }
364 commit0 = [PSCustomObject]@{ object = [PSCustomObject]@{ oid = '8e5e7e5ab8b370d6c329ec480221332ada57f0ab'; committedDate = (Get-Date).AddDays(-60).ToString('o') } }
365 commit1 = [PSCustomObject]@{ object = [PSCustomObject]@{ oid = '64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c'; committedDate = (Get-Date).AddDays(-45).ToString('o') } }
366 }
367 }
368 }
369 }
370 elseif ($Uri -like '*/releases/latest') {
371 $repoName = ($Uri -split '/')[-3]
372 return @{
373 tag_name = switch ($repoName) {
374 'actionlint' { 'v1.7.10' }
375 'gitleaks' { 'v8.30.0' }
376 default { 'v1.0.0' }
377 }
378 published_at = (Get-Date).AddMonths(-1).ToString('o')
379 }
380 }
381 return @{}
382 }
383 }
384
385 AfterAll {
386 # Restore original directory
387 Set-Location $script:OriginalLocation
388 }
389
390 Context 'Array coercion in main execution block' {
391 BeforeEach {
392 # Create workflow with SHA-pinned action
393 $workflowContent = @'
394name: Test
395on: push
396jobs:
397 test:
398 runs-on: ubuntu-latest
399 steps:
400 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
401'@
402 Set-Content -Path (Join-Path $script:WorkflowDir 'test.yml') -Value $workflowContent
403
404 # Change to test repo directory
405 Set-Location $script:TestRepo
406 }
407
408 AfterEach {
409 # Return to original location
410 Set-Location $script:OriginalLocation
411 }
412
413 It 'Executes array coercion when processing action repos' {
414 # This test executes the main script block which includes:
415 # - @($allActionRepos).Count checks (lines 532, 537)
416 # - @($Dependencies).Count checks throughout result formatting
417
418 $jsonPath = Join-Path $script:TestRepo 'logs' 'test-output.json'
419 & $scriptPath -OutputFormat 'json' -OutputPath $jsonPath *>&1 | Out-Null
420
421 # Validate JSON structure was created with array coercion
422 Test-Path $jsonPath | Should -BeTrue
423 $result = Get-Content $jsonPath | ConvertFrom-Json
424
425 # Verify array coercion created proper structure
426 $result.PSObject.Properties.Name | Should -Contain 'TotalStaleItems'
427 # JSON deserialization creates Int64 (long) not Int32
428 $result.TotalStaleItems | Should -BeOfType [long]
429 $result.PSObject.Properties.Name | Should -Contain 'Dependencies'
430 # Dependencies should be array (even if empty)
431 , $result.Dependencies | Should -BeOfType [System.Object[]]
432
433 # Verify mock API calls: 2 GraphQL batches (repos + commits) + 2 tool release checks
434 Should -Invoke Invoke-GitHubAPIWithRetry -ParameterFilter { $Uri -like '*graphql*' } -Times 2 -Exactly
435 Should -Invoke Invoke-GitHubAPIWithRetry -ParameterFilter { $Uri -like '*/releases/latest' } -Times 2 -Exactly
436 }
437
438 It 'Processes stale dependencies with array count operations' {
439 # Create multiple workflows to trigger grouping logic
440 for ($i = 1; $i -le 3; $i++) {
441 $workflowContent = @"
442name: Test$i
443on: push
444jobs:
445 test:
446 runs-on: ubuntu-latest
447 steps:
448 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
449 - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c
450"@
451 Set-Content -Path (Join-Path $script:WorkflowDir "test$i.yml") -Value $workflowContent
452 }
453
454 # This executes lines that group and count dependencies:
455 # - @($Dependencies | Group-Object Type) (line 753)
456 # - @($type.Group | Where-Object...).Count in summary building
457
458 $jsonPath = Join-Path $script:TestRepo 'logs' 'grouped-test.json'
459 & $scriptPath -OutputFormat 'json' -OutputPath $jsonPath *>&1 | Out-Null
460
461 # Validate grouping and counting worked
462 Test-Path $jsonPath | Should -BeTrue
463 $result = Get-Content $jsonPath | ConvertFrom-Json
464
465 # Should have processed multiple action repos (array coercion on line 532)
466 $result.TotalStaleItems | Should -BeOfType [long]
467 $result.TotalStaleItems | Should -Be 2
468
469 # Log file should contain evidence of array counting
470 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
471 Test-Path $logPath | Should -BeTrue
472 $logContent = Get-Content $logPath -Raw
473 # Should log count of repos found (uses @($allActionRepos).Count)
474 $logContent | Should -Match 'unique repositories.*SHA-pinned actions'
475 }
476
477 It 'Handles tool staleness checking with array coercion' {
478 # This executes tool checking code:
479 # - @($toolResults).Count (line 895)
480 # - @($staleTools).Count (line 897-898)
481 # - @($errorTools).Count (line 921-922)
482
483 $jsonPath = Join-Path $script:TestRepo 'logs' 'tool-check.json'
484 & $scriptPath -OutputPath $jsonPath -OutputFormat 'json' *>&1 | Out-Null
485
486 # Validate tool processing used array coercion
487 Test-Path $jsonPath | Should -BeTrue
488 $result = Get-Content $jsonPath | ConvertFrom-Json
489
490 # Result should have TotalStaleItems even if zero (proves @($toolResults).Count worked)
491 $result.PSObject.Properties.Name | Should -Contain 'TotalStaleItems'
492
493 # Check log for tool checking evidence
494 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
495 $logContent = Get-Content $logPath -Raw
496 $logContent | Should -Match 'Checking tool staleness'
497
498 # Verify API calls: 2 GraphQL batches + 2 tool release checks
499 Should -Invoke Invoke-GitHubAPIWithRetry -ParameterFilter { $Uri -like '*graphql*' } -Times 2 -Exactly
500 Should -Invoke Invoke-GitHubAPIWithRetry -ParameterFilter { $Uri -like '*/releases/latest' } -Times 2 -Exactly
501 }
502
503 It 'Executes result formatting with array operations' {
504 # This triggers formatting code with array coercion:
505 # - @($Dependencies).Count in various output formats (lines 706, 721, 731, 742, 747, 752)
506 # - TotalStaleItems = @($Dependencies).Count (line 676)
507
508 $jsonPath = Join-Path $script:TestRepo 'logs' 'format-test.json'
509 & $scriptPath -OutputFormat 'json' -OutputPath $jsonPath *>&1 | Out-Null
510
511 # Validate JSON output format uses array coercion correctly
512 Test-Path $jsonPath | Should -BeTrue
513 $jsonResult = Get-Content $jsonPath | ConvertFrom-Json
514
515 # Verify required fields from array operations
516 $jsonResult.PSObject.Properties.Name | Should -Contain 'TotalStaleItems'
517 $jsonResult.PSObject.Properties.Name | Should -Contain 'Dependencies'
518 $jsonResult.PSObject.Properties.Name | Should -Contain 'Timestamp'
519
520 # TotalStaleItems should be numeric from @($Dependencies).Count
521 $jsonResult.TotalStaleItems | Should -BeOfType [long]
522 $jsonResult.TotalStaleItems | Should -Be 2
523
524 # Test Summary format exercises array coercion (@($Dependencies).Count)
525 # The key is that it executes the @($Dependencies).Count operations
526 $summaryOutput = & $scriptPath -OutputFormat 'Summary' 2>&1 | Out-String
527 $summaryOutput | Should -Match "(Total stale dependencies:|No stale dependencies detected)"
528 }
529 }
530
531 Context 'CI environment integration' {
532 BeforeEach {
533 # Save original environment
534 $script:OriginalGHA = $env:GITHUB_ACTIONS
535 $script:OriginalADO = $env:TF_BUILD
536 $script:OriginalGHOutput = $env:GITHUB_OUTPUT
537
538 # Create test workflow
539 $workflowContent = @'
540name: CI Test
541on: push
542jobs:
543 test:
544 runs-on: ubuntu-latest
545 steps:
546 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
547'@
548 Set-Content -Path (Join-Path $script:WorkflowDir 'ci.yml') -Value $workflowContent
549
550 # Change to test repo
551 Set-Location $script:TestRepo
552 }
553
554 AfterEach {
555 $env:GITHUB_ACTIONS = $script:OriginalGHA
556 $env:TF_BUILD = $script:OriginalADO
557 $env:GITHUB_OUTPUT = $script:OriginalGHOutput
558 Set-Location $script:OriginalLocation
559 }
560
561 It 'Executes GitHub Actions output formatting with array coercion' {
562 $env:GITHUB_ACTIONS = 'true'
563 $outputFile = Join-Path $script:TestRepo 'github-output.txt'
564 $env:GITHUB_OUTPUT = $outputFile
565
566 # This triggers GitHub Actions specific formatting (lines 706-715)
567 # which includes @($Dependencies).Count checks
568
569 & $scriptPath -OutputFormat 'github' *>&1 | Out-Null
570
571 # GitHub Actions format writes to GITHUB_OUTPUT, verify it was created
572 if ($outputFile -and (Test-Path $outputFile)) {
573 # Output file should have workflow command format
574 $content = Get-Content $outputFile -Raw
575 $content | Should -Not -BeNullOrEmpty
576 }
577
578 # Verify log shows proper array counting
579 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
580 $logContent = Get-Content $logPath -Raw
581 # Should mention "stale dependencies found" with count (uses @($Dependencies).Count)
582 $logContent | Should -Match 'Stale dependencies found: \d+'
583 }
584
585 It 'Executes Azure DevOps output formatting with array coercion' {
586 $env:TF_BUILD = 'true'
587
588 # This triggers ADO specific formatting (lines 721-729)
589 # which includes @($Dependencies).Count checks
590
591 & $scriptPath -OutputFormat 'azdo' *>&1 | Out-Null
592
593 # Azure DevOps format includes task.logissue commands
594 # Validates that @($Dependencies).Count was evaluated
595 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
596 $logContent = Get-Content $logPath -Raw
597 $logContent | Should -Match 'Stale dependencies found: \d+'
598 }
599
600 It 'Executes console output formatting with array coercion' {
601 # No CI environment - uses console output (lines 731-755)
602 # Includes @($Dependencies).Count and grouping operations
603
604 # Console format doesn't create output file unless -OutputPath specified
605 & $scriptPath -OutputFormat 'console' *>&1 | Out-Null
606
607 # Verify log contains array coercion evidence
608 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
609 Test-Path $logPath | Should -BeTrue
610 $logContent = Get-Content $logPath -Raw
611 # Should have processed and counted (uses @($Dependencies).Count)
612 $logContent | Should -Match 'SHA staleness monitoring completed'
613 $logContent | Should -Match 'Stale dependencies found: \d+'
614 }
615 }
616
617 Context 'Empty and edge case scenarios' {
618 BeforeEach {
619 Set-Location $script:TestRepo
620 }
621
622 AfterEach {
623 Set-Location $script:OriginalLocation
624 }
625
626 It 'Handles empty workflow directory with array coercion' {
627 # Remove all workflow files
628 Get-ChildItem $script:WorkflowDir -Filter "*.yml" | Remove-Item -Force
629
630 # This should execute array coercion on empty collections
631 # Testing @($allActionRepos).Count -eq 0 branch (line 532)
632
633 $jsonPath = Join-Path $script:TestRepo 'logs' 'empty-test.json'
634 & $scriptPath -OutputFormat 'json' -OutputPath $jsonPath *>&1 | Out-Null
635
636 # Validate empty array handling
637 Test-Path $jsonPath | Should -BeTrue
638 $result = Get-Content $jsonPath | ConvertFrom-Json
639
640 # Should show 0 items (proves @($allActionRepos).Count -eq 0 worked)
641 $result.TotalStaleItems | Should -Be 0
642
643 # Log should indicate no SHA-pinned actions found
644 $logPath = Join-Path $script:TestRepo 'logs' 'sha-staleness-monitoring.log'
645 $logContent = Get-Content $logPath -Raw
646 $logContent | Should -Match 'No SHA-pinned.*found|No stale dependencies'
647
648 # No actions found so no GraphQL calls, but tool staleness still runs
649 Should -Invoke Invoke-GitHubAPIWithRetry -ParameterFilter { $Uri -like '*graphql*' } -Times 0 -Exactly
650 Should -Invoke Invoke-GitHubAPIWithRetry -ParameterFilter { $Uri -like '*/releases/latest' } -Times 2 -Exactly
651 }
652
653 It 'Processes single stale dependency with array coercion' {
654 # Create single workflow
655 $singleWorkflow = @'
656name: Single
657on: push
658jobs:
659 test:
660 runs-on: ubuntu-latest
661 steps:
662 - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
663'@
664 Set-Content -Path (Join-Path $script:WorkflowDir 'single.yml') -Value $singleWorkflow
665
666 # Script-scope safety-net mock -- REST fallback calls now route through SecurityHelpers.
667 Mock Invoke-RestMethod {
668 if ($Uri -like '*/repos/*/branches/*') {
669 return @{ commit = @{ sha = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' } }
670 }
671 elseif ($Uri -like '*/repos/*/commits/*') {
672 return @{ commit = @{ author = @{ date = (Get-Date).AddMonths(-6).ToString('o') } } }
673 }
674 elseif ($Uri -like '*/repos/*') {
675 return @{ default_branch = 'main' }
676 }
677 return @{}
678 }
679
680 # Module-scope mock for Invoke-RestMethod calls inside SecurityHelpers.
681 # Uses aliased GraphQL response structure matching the script's batch queries.
682 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
683 if ($Uri -like '*graphql*') {
684 if ($Body -match 'viewer') {
685 return @{
686 data = @{
687 viewer = @{ login = 'testuser' }
688 rateLimit = @{ remaining = 5000; limit = 5000; resetAt = (Get-Date).AddHours(1).ToString('o') }
689 }
690 }
691 }
692 elseif ($Body -match 'defaultBranchRef') {
693 # Single repo -- latest SHA differs from pinned SHA to trigger stale detection
694 return @{
695 data = @{
696 rateLimit = @{ remaining = 5000; limit = 5000; used = 1; resetAt = (Get-Date).AddHours(1).ToString('o') }
697 repo0 = @{ name = 'checkout'; defaultBranchRef = @{ target = @{ oid = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; committedDate = (Get-Date).ToString('o') } } }
698 }
699 }
700 }
701 else {
702 # Commit batch -- PSCustomObject required for PSObject.Properties iteration
703 return [PSCustomObject]@{
704 data = [PSCustomObject]@{
705 rateLimit = [PSCustomObject]@{ remaining = 5000; cost = 1 }
706 commit0 = [PSCustomObject]@{ object = [PSCustomObject]@{ oid = '8e5e7e5ab8b370d6c329ec480221332ada57f0ab'; committedDate = (Get-Date).AddMonths(-6).ToString('o') } }
707 }
708 }
709 }
710 }
711 return @{}
712 }
713
714 # Single item return should be coerced to array, also tests stale detection
715 $jsonPath = Join-Path $script:TestRepo 'logs' 'single-test.json'
716 & $scriptPath -OutputFormat 'json' -OutputPath $jsonPath *>&1 | Out-Null
717
718 # Validate single item is properly handled as array
719 Test-Path $jsonPath | Should -BeTrue
720 $result = Get-Content $jsonPath | ConvertFrom-Json
721
722 # Single dependency should still produce numeric count (not $null)
723 $result.TotalStaleItems | Should -BeOfType [long]
724 $result.TotalStaleItems | Should -Be 1
725 # Dependencies array should exist
726 $result.PSObject.Properties.Name | Should -Contain 'Dependencies'
727 $result.Dependencies | Should -Not -BeNullOrEmpty
728 $result.Dependencies[0].PSObject.Properties.Name | Should -Contain 'Type'
729 }
730 }
731}
732
733Describe 'Get-BulkGitHubActionsStaleness' -Tag 'Unit' {
734 BeforeAll {
735 Save-CIEnvironment
736 }
737 AfterAll {
738 Restore-CIEnvironment
739 }
740
741 Context 'Token resolution' {
742 BeforeEach {
743 $env:GITHUB_TOKEN = ''
744 $env:SYSTEM_ACCESSTOKEN = ''
745 $env:GH_TOKEN = ''
746 $env:BUILD_REPOSITORY_PROVIDER = ''
747 }
748
749 It 'Uses GITHUB_TOKEN when available' {
750 $env:GITHUB_TOKEN = 'ghp_test_token_123'
751 Mock Test-GitHubToken { return @{ Valid = $true; Authenticated = $true; RateLimit = @{ remaining = 5000 }; Message = '' } }
752 Mock Invoke-GitHubAPIWithRetry {
753 return @{
754 data = @{
755 rateLimit = @{ remaining = 5000 }
756 repo0 = @{ defaultBranchRef = @{ target = @{ oid = 'bbbb' * 10; committedDate = (Get-Date).ToString('o') } } }
757 }
758 }
759 }
760
761 $sha = 'aaaa' * 10
762 $null = Get-BulkGitHubActionsStaleness -ActionRepos @('owner/repo') -ShaToActionMap @{
763 "owner/repo@$sha" = @{ Repo = 'owner/repo'; SHA = $sha; File = 'test.yml' }
764 }
765
766 Should -Invoke Test-GitHubToken -Times 1
767 }
768
769 It 'Falls back to GH_TOKEN when GITHUB_TOKEN is empty' {
770 $env:GH_TOKEN = 'ghp_fallback_token'
771 Mock Test-GitHubToken { return @{ Valid = $true; Authenticated = $true; RateLimit = @{ remaining = 5000 }; Message = '' } }
772 Mock Invoke-GitHubAPIWithRetry {
773 return @{
774 data = @{
775 rateLimit = @{ remaining = 5000 }
776 repo0 = @{ defaultBranchRef = @{ target = @{ oid = 'bbbb' * 10; committedDate = (Get-Date).ToString('o') } } }
777 }
778 }
779 }
780
781 $sha = 'aaaa' * 10
782 $null = Get-BulkGitHubActionsStaleness -ActionRepos @('owner/repo') -ShaToActionMap @{
783 "owner/repo@$sha" = @{ Repo = 'owner/repo'; SHA = $sha; File = 'test.yml' }
784 }
785
786 Should -Invoke Test-GitHubToken -Times 1
787 }
788
789 It 'Uses SYSTEM_ACCESSTOKEN for GitHub-hosted ADO repos' {
790 $env:SYSTEM_ACCESSTOKEN = 'ado_token'
791 $env:BUILD_REPOSITORY_PROVIDER = 'GitHub'
792 Mock Test-GitHubToken { return @{ Valid = $true; Authenticated = $true; RateLimit = @{ remaining = 5000 }; Message = '' } }
793 Mock Invoke-GitHubAPIWithRetry {
794 return @{
795 data = @{
796 rateLimit = @{ remaining = 5000 }
797 repo0 = @{ defaultBranchRef = @{ target = @{ oid = 'bbbb' * 10; committedDate = (Get-Date).ToString('o') } } }
798 }
799 }
800 }
801
802 $sha = 'aaaa' * 10
803 $null = Get-BulkGitHubActionsStaleness -ActionRepos @('owner/repo') -ShaToActionMap @{
804 "owner/repo@$sha" = @{ Repo = 'owner/repo'; SHA = $sha; File = 'test.yml' }
805 }
806
807 Should -Invoke Test-GitHubToken -Times 1
808 }
809 }
810
811 Context 'GraphQL batch processing' {
812 BeforeEach {
813 $env:GITHUB_TOKEN = 'ghp_test_token'
814 }
815
816 It 'Returns stale result when SHA differs and age exceeds threshold' {
817 Mock Test-GitHubToken { return @{ Valid = $true; Authenticated = $true; RateLimit = @{ remaining = 5000 }; Message = '' } }
818
819 $latestSHA = 'bbbb' * 10
820 $currentSHA = 'aaaa' * 10
821 $oldDate = (Get-Date).AddDays(-60).ToString('o')
822 $newDate = (Get-Date).ToString('o')
823
824 # Default branch query
825 Mock Invoke-GitHubAPIWithRetry {
826 return @{
827 data = @{
828 rateLimit = @{ remaining = 5000 }
829 repo0 = @{ defaultBranchRef = @{ target = @{ oid = $latestSHA; committedDate = $newDate } } }
830 }
831 }
832 } -ParameterFilter { $Body -match 'defaultBranchRef' }
833
834 # Commit query - use [PSCustomObject] so PSObject.Properties iteration works
835 Mock Invoke-GitHubAPIWithRetry {
836 return [PSCustomObject]@{
837 data = [PSCustomObject]@{
838 rateLimit = [PSCustomObject]@{ remaining = 5000 }
839 commit0 = [PSCustomObject]@{
840 object = [PSCustomObject]@{
841 oid = $currentSHA
842 committedDate = $oldDate
843 }
844 }
845 }
846 }
847 } -ParameterFilter { $Body -match 'commit0' }
848
849 $result = Get-BulkGitHubActionsStaleness -ActionRepos @('actions/checkout') -ShaToActionMap @{
850 "actions/checkout@$currentSHA" = @{ Repo = 'actions/checkout'; SHA = $currentSHA; File = 'ci.yml' }
851 }
852
853 $result | Should -Not -BeNullOrEmpty
854 @($result).Count | Should -BeGreaterOrEqual 1
855 Should -Invoke Invoke-GitHubAPIWithRetry -Times 2
856 }
857 }
858
859 Context 'Invalid token' {
860 BeforeEach {
861 $env:GITHUB_TOKEN = ''
862 $env:SYSTEM_ACCESSTOKEN = ''
863 $env:GH_TOKEN = ''
864 $env:BUILD_REPOSITORY_PROVIDER = ''
865 }
866
867 It 'Returns empty when no valid token is available' {
868 Mock Test-GitHubToken { return @{ Valid = $false; Authenticated = $false; Message = 'No token' } }
869 Mock Write-SecurityLog { }
870 Mock Invoke-GitHubAPIWithRetry {
871 return @{
872 data = @{
873 rateLimit = @{ remaining = 60 }
874 repo0 = @{ defaultBranchRef = $null }
875 }
876 }
877 }
878
879 $sha = 'aaaa' * 10
880 $result = Get-BulkGitHubActionsStaleness -ActionRepos @('owner/repo') -ShaToActionMap @{
881 "owner/repo@$sha" = @{ Repo = 'owner/repo'; SHA = $sha; File = 'test.yml' }
882 }
883
884 @($result).Count | Should -Be 0
885 Should -Invoke Test-GitHubToken -Times 1
886 }
887 }
888}
889
890Describe 'Test-GitHubActionsForStaleness' -Tag 'Unit' {
891 BeforeAll {
892 Save-CIEnvironment
893 $script:TestWorkflows = Join-Path $TestDrive '.github' 'workflows'
894 New-Item -ItemType Directory -Path $script:TestWorkflows -Force | Out-Null
895 }
896 AfterAll {
897 Restore-CIEnvironment
898 }
899
900 Context 'Workflow scanning' {
901 It 'Returns empty when no SHA-pinned actions found' {
902 $ymlContent = @'
903name: test
904on: push
905jobs:
906 build:
907 runs-on: ubuntu-latest
908 steps:
909 - uses: actions/checkout@v4
910'@
911 Set-Content (Join-Path $script:TestWorkflows 'no-sha.yml') -Value $ymlContent
912
913 Push-Location $TestDrive
914 try {
915 Mock Write-SecurityLog { }
916 Mock Get-BulkGitHubActionsStaleness { return @() }
917
918 $null = Test-GitHubActionsForStaleness
919
920 # No SHA-pinned actions found = early return
921 Should -Not -Invoke Get-BulkGitHubActionsStaleness
922 }
923 finally {
924 Pop-Location
925 }
926 }
927
928 It 'Detects SHA-pinned actions in workflow files' {
929 $sha = 'a' * 40
930 $ymlContent = @"
931name: test
932on: push
933jobs:
934 build:
935 runs-on: ubuntu-latest
936 steps:
937 - uses: actions/checkout@$sha
938"@
939 Set-Content (Join-Path $script:TestWorkflows 'pinned.yml') -Value $ymlContent
940
941 Push-Location $TestDrive
942 try {
943 Mock Write-SecurityLog { }
944 Mock Get-BulkGitHubActionsStaleness { return @() }
945
946 $null = Test-GitHubActionsForStaleness
947
948 Should -Invoke Get-BulkGitHubActionsStaleness -Times 1
949 }
950 finally {
951 Pop-Location
952 }
953 }
954 }
955
956 Context 'No workflow directory' {
957 It 'Returns empty when .github/workflows does not exist' {
958 $emptyDir = Join-Path $TestDrive 'empty-project'
959 New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null
960
961 Push-Location $emptyDir
962 try {
963 Mock Write-SecurityLog { }
964
965 $result = Test-GitHubActionsForStaleness
966 @($result).Count | Should -Be 0
967 }
968 finally {
969 Pop-Location
970 }
971 }
972 }
973}
974
975Describe 'Write-SecurityOutput' -Tag 'Unit' {
976 Context 'JSON output format' {
977 It 'Creates output file with correct structure' {
978 $jsonPath = Join-Path $TestDrive 'output.json'
979 $deps = @(
980 @{ Type = 'GitHubAction'; Name = 'actions/checkout'; DaysOld = 45; Severity = 'Low' }
981 )
982
983 Write-SecurityOutput -Dependencies $deps -OutputFormat 'json' -OutputPath $jsonPath
984
985 Test-Path $jsonPath | Should -BeTrue
986 $content = Get-Content $jsonPath | ConvertFrom-Json
987 $content.TotalStaleItems | Should -Be 1
988 }
989 }
990
991 Context 'Console output format' {
992 It 'Writes formatted output via Write-SecurityLog' {
993 Mock Write-SecurityLog { }
994
995 $deps = @(
996 @{ Type = 'GitHubAction'; ActionRepo = 'actions/checkout'; DaysOld = 45; Severity = 'Low'; File = 'ci.yml' }
997 )
998
999 Write-SecurityOutput -Dependencies $deps -OutputFormat 'console'
1000
1001 Should -Invoke Write-SecurityLog -Times 1
1002 }
1003 }
1004
1005 Context 'Summary output format' {
1006 It 'Groups dependencies by type' {
1007 Mock Write-Output { }
1008
1009 $deps = @(
1010 @{ Type = 'GitHubAction'; Name = 'actions/checkout'; DaysOld = 45; Severity = 'Low' }
1011 @{ Type = 'Tool'; Name = 'node'; DaysOld = 90; Severity = 'High' }
1012 )
1013
1014 Write-SecurityOutput -Dependencies $deps -OutputFormat 'Summary'
1015
1016 Should -Invoke Write-Output -Times 1
1017 }
1018 }
1019
1020 Context 'GitHub output format with stale dependencies' {
1021 BeforeAll {
1022 # Write-SecurityOutput receives pre-filtered stale items; all entries are stale by definition
1023 $script:githubDeps = @(
1024 @{ Type = 'GitHubAction'; Name = 'actions/checkout'; DaysOld = 45; Severity = 'Low'; File = 'ci.yml'; Message = 'GitHub Action is 45 days old' }
1025 @{ Type = 'GitHubAction'; Name = 'actions/setup-node'; DaysOld = 90; Severity = 'High'; File = 'build.yml'; Message = 'GitHub Action is 90 days old' }
1026 )
1027 }
1028
1029 BeforeEach {
1030 Mock Write-CIAnnotation { }
1031 Mock Write-CIStepSummary { }
1032 Write-SecurityOutput -Dependencies $script:githubDeps -OutputFormat 'github'
1033 }
1034
1035 It 'Calls Write-CIAnnotation for each dependency with Warning level' {
1036 Should -Invoke Write-CIAnnotation -Times 2 -ParameterFilter { $Level -eq 'Warning' }
1037 }
1038
1039 It 'Calls Write-CIAnnotation aggregate with Error level' {
1040 Should -Invoke Write-CIAnnotation -Times 1 -ParameterFilter { $Level -eq 'Error' }
1041 }
1042
1043 It 'Calls Write-CIAnnotation total of 3 times (2 per-item + 1 aggregate)' {
1044 Should -Invoke Write-CIAnnotation -Times 3 -Exactly
1045 }
1046
1047 It 'Calls Write-CIStepSummary exactly once' {
1048 Should -Invoke Write-CIStepSummary -Times 1 -Exactly
1049 }
1050
1051 It 'Passes markdown containing the summary table header' {
1052 Should -Invoke Write-CIStepSummary -Times 1 -ParameterFilter {
1053 $Content -match '\| Dependency \| SHA Age \(days\) \| Threshold \(days\) \| Status \|'
1054 }
1055 }
1056
1057 It 'Includes dependency names in summary content' {
1058 Should -Invoke Write-CIStepSummary -Times 1 -ParameterFilter {
1059 $Content -match 'actions/checkout' -and $Content -match 'actions/setup-node'
1060 }
1061 }
1062
1063 It 'Shows stale status for all dependencies' {
1064 Should -Invoke Write-CIStepSummary -Times 1 -ParameterFilter {
1065 $Content -match 'Stale'
1066 }
1067 }
1068
1069 It 'Includes totals in summary content' {
1070 Should -Invoke Write-CIStepSummary -Times 1 -ParameterFilter {
1071 $Content -match 'Found:.+2' -and $Content -match 'Stale:.+2'
1072 }
1073 }
1074 }
1075
1076 Context 'GitHub output format with no stale dependencies' {
1077 BeforeEach {
1078 Mock Write-CIAnnotation { }
1079 Mock Write-CIStepSummary { }
1080 Write-SecurityOutput -Dependencies @() -OutputFormat 'github'
1081 }
1082
1083 It 'Calls Write-CIAnnotation with Notice level for no stale deps' {
1084 Should -Invoke Write-CIAnnotation -Times 1 -ParameterFilter { $Level -eq 'Notice' }
1085 }
1086
1087 It 'Calls Write-CIAnnotation exactly once' {
1088 Should -Invoke Write-CIAnnotation -Times 1 -Exactly
1089 }
1090
1091 It 'Calls Write-CIStepSummary exactly once' {
1092 Should -Invoke Write-CIStepSummary -Times 1 -Exactly
1093 }
1094
1095 It 'Passes all-clear summary when no dependencies' {
1096 Should -Invoke Write-CIStepSummary -Times 1 -ParameterFilter {
1097 $Content -match 'All Clear' -and $Content -match 'No stale dependencies detected'
1098 }
1099 }
1100 }
1101}
1102
1103Describe 'Invoke-SHAStalenessCheck' -Tag 'Unit' {
1104 BeforeAll {
1105 Save-CIEnvironment
1106 }
1107 AfterAll {
1108 Restore-CIEnvironment
1109 }
1110
1111 Context 'Log directory creation' {
1112 It 'Creates log directory when it does not exist' {
1113 $logPath = Join-Path $TestDrive 'staleness-logs' 'test.log'
1114 $env:GITHUB_TOKEN = 'ghp_test'
1115
1116 Mock Test-GitHubActionsForStaleness { return @() }
1117 Mock Get-ToolStaleness { }
1118 Mock Write-SecurityOutput { }
1119 Mock New-Item { } -ParameterFilter { $ItemType -eq 'Directory' }
1120 Mock Write-SecurityLog { }
1121
1122 Invoke-SHAStalenessCheck -OutputFormat 'console' -LogPath $logPath
1123
1124 Should -Invoke New-Item -Times 1
1125 }
1126 }
1127
1128 Context 'FailOnStale behavior' {
1129 It 'Throws when stale dependencies are detected and FailOnStale is set' {
1130 $env:GITHUB_TOKEN = 'ghp_test'
1131 Mock Write-SecurityLog { }
1132 Mock New-Item { }
1133 Mock Test-GitHubActionsForStaleness {
1134 $script:StaleDependencies = @(
1135 @{ Type = 'GitHubAction'; Name = 'actions/checkout'; DaysOld = 45 }
1136 )
1137 }
1138 Mock Get-ToolStaleness { }
1139 Mock Write-SecurityOutput { }
1140
1141 { Invoke-SHAStalenessCheck -OutputFormat 'console' -FailOnStale } |
1142 Should -Throw '*Stale dependencies detected*'
1143 }
1144
1145 It 'Does not throw when no stale dependencies and FailOnStale is set' {
1146 $env:GITHUB_TOKEN = 'ghp_test'
1147 Mock Write-SecurityLog { }
1148 Mock New-Item { }
1149 Mock Test-GitHubActionsForStaleness {
1150 $script:StaleDependencies = @()
1151 }
1152 Mock Get-ToolStaleness { }
1153 Mock Write-SecurityOutput { }
1154
1155 { Invoke-SHAStalenessCheck -OutputFormat 'console' -FailOnStale } |
1156 Should -Not -Throw
1157 }
1158 }
1159}