microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/issue-890-python-testing-ci

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

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