microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat-ds-agent

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

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