microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
fix/dependabot-uuid-postcss-overrides

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/tests/security/Update-ActionSHAPinning.Tests.ps1

802lines · modecode

1#Requires -Modules Pester
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4
5BeforeAll {
6 $scriptPath = Join-Path $PSScriptRoot '../../security/Update-ActionSHAPinning.ps1'
7 . $scriptPath
8
9 $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1'
10 Import-Module $mockPath -Force
11
12 # Save environment before tests
13 Save-CIEnvironment
14
15 # Fixture paths
16 $script:FixturesPath = Join-Path $PSScriptRoot '../Fixtures/Workflows'
17
18 # Mock response helpers
19 function script:New-MockGitHubGraphQLResponse {
20 param(
21 [string]$Login = 'testuser',
22 [int]$RateRemaining = 5000,
23 [int]$RateLimit = 5000
24 )
25 return @{
26 data = @{
27 viewer = @{ login = $Login }
28 rateLimit = @{
29 remaining = $RateRemaining
30 limit = $RateLimit
31 resetAt = (Get-Date).AddHours(1).ToString('o')
32 }
33 }
34 }
35 }
36
37 function script:New-MockRateLimitException {
38 $exception = [System.Net.WebException]::new(
39 "API rate limit exceeded",
40 $null,
41 [System.Net.WebExceptionStatus]::ProtocolError,
42 $null
43 )
44 return $exception
45 }
46}
47
48AfterAll {
49 Restore-CIEnvironment
50}
51
52Describe 'Get-ActionReference' -Tag 'Unit' {
53 Context 'Standard action references' {
54 It 'Parses action with tag reference' {
55 $yaml = 'uses: actions/checkout@v4'
56 $result = Get-ActionReference -WorkflowContent $yaml
57 $result | Should -Not -BeNullOrEmpty
58 $result.OriginalRef | Should -Be 'actions/checkout@v4'
59 }
60
61 It 'Parses action with SHA reference' {
62 $yaml = 'uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29'
63 $result = Get-ActionReference -WorkflowContent $yaml
64 $result.OriginalRef | Should -Be 'actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29'
65 }
66
67 It 'Returns LineNumber for reference' {
68 $yaml = "name: Test`njobs:`n test:`n steps:`n - name: Checkout`n uses: actions/checkout@v4"
69 $result = Get-ActionReference -WorkflowContent $yaml
70 $result.LineNumber | Should -BeGreaterThan 0
71 }
72 }
73
74 Context 'Multiple action references' {
75 It 'Finds all action references in workflow' {
76 $yaml = "jobs:`n test:`n steps:`n - name: Checkout`n uses: actions/checkout@v4`n - name: Setup`n uses: actions/setup-node@v4"
77 $result = @(Get-ActionReference -WorkflowContent $yaml)
78 $result.Count | Should -Be 2
79 }
80 }
81
82 Context 'Invalid references' {
83 It 'Returns empty for non-action content' {
84 $yaml = 'run: echo "Hello"'
85 $result = Get-ActionReference -WorkflowContent $yaml
86 $result | Should -BeNullOrEmpty
87 }
88 }
89}
90
91Describe 'Get-SHAForAction' -Tag 'Unit' {
92 BeforeEach {
93 Initialize-MockCIEnvironment
94 $env:GITHUB_TOKEN = 'ghp_test123456789'
95 }
96
97 AfterEach {
98 Clear-MockCIEnvironment
99 }
100
101 Context 'ActionSHAMap lookup' {
102 It 'Returns action reference with SHA for known action' {
103 $result = Get-SHAForAction -ActionRef 'actions/checkout@v4'
104 $result | Should -Not -BeNullOrEmpty
105 # Function returns full action reference with SHA (e.g., actions/checkout@sha)
106 $result | Should -Match '@[a-f0-9]{40}$'
107 }
108 }
109
110 Context 'Unmapped actions' {
111 It 'Returns null when action not in map and no API call is made' {
112 # Get-SHAForAction returns null for unmapped actions without attempting API
113 $result = Get-SHAForAction -ActionRef 'unknown/action@v1'
114 $result | Should -BeNullOrEmpty
115 }
116
117 It 'Returns null for unmapped actions requiring manual review' {
118 # The function logs a warning and returns null for unmapped actions
119 $result = Get-SHAForAction -ActionRef 'test-org/test-action@v1'
120 $result | Should -BeNullOrEmpty
121 }
122 }
123}
124
125Describe 'Update-WorkflowFile' -Tag 'Unit' {
126 BeforeEach {
127 Initialize-MockCIEnvironment
128 $env:GITHUB_TOKEN = 'ghp_test123456789'
129
130 # Copy fixture to TestDrive for modification testing
131 $unpinnedSource = Join-Path $script:FixturesPath 'unpinned-workflow.yml'
132 $script:TestWorkflow = Join-Path $TestDrive 'test-workflow.yml'
133 Copy-Item $unpinnedSource $script:TestWorkflow
134
135 # Mock at function level; Update-WorkflowFile -> Get-SHAForAction -> Get-LatestCommitSHA
136 # calls module functions that bare Invoke-RestMethod mocks cannot intercept
137 Mock Get-LatestCommitSHA {
138 return 'newsha123456789012345678901234567890abcd'
139 }
140 }
141
142 AfterEach {
143 Clear-MockCIEnvironment
144 }
145
146 Context 'Return value structure' {
147 It 'Returns PSCustomObject with FilePath' {
148 $result = Update-WorkflowFile -FilePath $script:TestWorkflow
149 $result | Should -BeOfType [PSCustomObject]
150 $result.FilePath | Should -Be $script:TestWorkflow
151 }
152
153 It 'Returns ActionsProcessed count' {
154 $result = Update-WorkflowFile -FilePath $script:TestWorkflow
155 $result.ActionsProcessed | Should -BeGreaterOrEqual 0
156 }
157
158 It 'Returns ActionsPinned count' {
159 $result = Update-WorkflowFile -FilePath $script:TestWorkflow
160 $result.PSObject.Properties.Name -contains 'ActionsPinned' | Should -BeTrue
161 }
162 }
163
164 Context 'File modification' {
165 It 'Updates unpinned action to SHA' {
166 Update-WorkflowFile -FilePath $script:TestWorkflow
167
168 $content = Get-Content $script:TestWorkflow -Raw
169 # Check that the file was processed (content may or may not change based on mock)
170 $content | Should -Not -BeNullOrEmpty
171 }
172 }
173
174 Context 'Already pinned workflows' {
175 It 'Does not modify already pinned actions' {
176 $pinnedSource = Join-Path $script:FixturesPath 'pinned-workflow.yml'
177 $pinnedTest = Join-Path $TestDrive 'pinned-test.yml'
178 Copy-Item $pinnedSource $pinnedTest
179
180 $originalContent = Get-Content $pinnedTest -Raw
181 Update-WorkflowFile -FilePath $pinnedTest
182 $newContent = Get-Content $pinnedTest -Raw
183
184 $newContent | Should -Be $originalContent
185 }
186 }
187}
188
189Describe 'Update-WorkflowFile -WhatIf' -Tag 'Unit' {
190 BeforeEach {
191 Initialize-MockCIEnvironment
192 $env:GITHUB_TOKEN = 'ghp_test123456789'
193
194 $unpinnedSource = Join-Path $script:FixturesPath 'unpinned-workflow.yml'
195 $script:TestWorkflow = Join-Path $TestDrive 'whatif-test.yml'
196 Copy-Item $unpinnedSource $script:TestWorkflow
197
198 # Mock at function level; Update-WorkflowFile -> Get-SHAForAction -> Get-LatestCommitSHA
199 # calls module functions that bare Invoke-RestMethod mocks cannot intercept
200 Mock Get-LatestCommitSHA {
201 return 'newsha123456789012345678901234567890abcd'
202 }
203 }
204
205 AfterEach {
206 Clear-MockCIEnvironment
207 }
208
209 Context 'WhatIf behavior' {
210 It 'Does not modify file when WhatIf is specified' {
211 $originalContent = Get-Content $script:TestWorkflow -Raw
212
213 Update-WorkflowFile -FilePath $script:TestWorkflow -WhatIf
214
215 $newContent = Get-Content $script:TestWorkflow -Raw
216 $newContent | Should -Be $originalContent
217 }
218 }
219}
220
221Describe 'Invoke-GitHubAPIWithRetry' -Tag 'Unit' {
222 BeforeEach {
223 Initialize-MockCIEnvironment
224 $env:GITHUB_TOKEN = 'ghp_test123456789'
225 $script:AttemptCount = 0
226
227 # Mock Start-Sleep to avoid actual delays
228 Mock Start-Sleep -ModuleName SecurityHelpers { }
229 }
230
231 AfterEach {
232 Clear-MockCIEnvironment
233 }
234
235 Context 'Successful requests' {
236 It 'Returns response on first attempt success' {
237 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
238 $script:AttemptCount++
239 return @{ data = 'success' }
240 }
241
242 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'GET' -Headers @{ Authorization = 'token test' }
243
244 $result.data | Should -Be 'success'
245 $script:AttemptCount | Should -Be 1
246 Should -Not -Invoke Start-Sleep -ModuleName SecurityHelpers
247 }
248 }
249
250 Context 'Rate limit retry behavior' {
251 It 'Retries on 403 rate limit error and succeeds' {
252 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
253 $script:AttemptCount++
254 if ($script:AttemptCount -lt 3) {
255 # Create exception with proper Response.StatusCode for rate limit detection
256 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Forbidden)
257 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('API rate limit exceeded', $response)
258 throw $exception
259 }
260 return @{ data = 'success after retry' }
261 }
262
263 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'GET' -Headers @{ Authorization = 'token test' } -MaxRetries 5
264
265 $result.data | Should -Be 'success after retry'
266 $script:AttemptCount | Should -Be 3
267 Should -Invoke Start-Sleep -ModuleName SecurityHelpers -Times 2
268 }
269
270 It 'Returns null after exceeding MaxRetries' {
271 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
272 $script:AttemptCount++
273 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Forbidden)
274 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('API rate limit exceeded', $response)
275 throw $exception
276 }
277
278 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'GET' -Headers @{ Authorization = 'token test' } -MaxRetries 2
279 $result | Should -BeNullOrEmpty
280 $script:AttemptCount | Should -Be 2
281 }
282
283 It 'Uses exponential backoff delay' {
284 $script:delays = @()
285 Mock Start-Sleep -ModuleName SecurityHelpers { param($Seconds) $script:delays += $Seconds }
286 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
287 $script:AttemptCount++
288 if ($script:AttemptCount -lt 3) {
289 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Forbidden)
290 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('API rate limit exceeded', $response)
291 throw $exception
292 }
293 return @{ data = 'success' }
294 }
295
296 Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'GET' -Headers @{ Authorization = 'token test' } -InitialDelaySeconds 2
297
298 # Verify exponential backoff pattern
299 $script:delays[0] | Should -Be 2 # First delay
300 $script:delays[1] | Should -Be 4 # Second delay (doubled)
301 }
302 }
303
304 Context 'Non-retryable errors' {
305 It 'Returns null on non-rate-limit error' {
306 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
307 $script:AttemptCount++
308 throw [System.Net.WebException]::new('Not Found')
309 }
310
311 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'GET' -Headers @{ Authorization = 'token test' }
312
313 $result | Should -BeNullOrEmpty
314 $script:AttemptCount | Should -Be 1
315 Should -Not -Invoke Start-Sleep -ModuleName SecurityHelpers
316 }
317 }
318
319 Context 'Request with body' {
320 It 'Includes body in request' {
321 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
322 param($Uri, $Method, $Headers, $Body, $ContentType)
323 $null = $Uri, $Method, $Headers # Suppress PSScriptAnalyzer unused parameter warnings
324 return @{ received = $Body; contentType = $ContentType }
325 }
326
327 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/graphql' -Method 'POST' -Headers @{ Authorization = 'token test' } -Body '{"query":"test"}'
328
329 $result.received | Should -Be '{"query":"test"}'
330 $result.contentType | Should -Be 'application/json'
331 }
332 }
333}
334
335Describe 'Get-LatestCommitSHA' -Tag 'Unit' {
336 BeforeEach {
337 Initialize-MockCIEnvironment
338 $env:GITHUB_TOKEN = 'ghp_test123456789'
339 }
340
341 AfterEach {
342 Clear-MockCIEnvironment
343 }
344
345 Context 'Successful SHA retrieval' {
346 It 'Returns SHA for valid repository and branch' {
347 Mock Test-GitHubToken { return @{ Valid = $true } }
348 Mock Invoke-GitHubAPIWithRetry {
349 return @{ sha = 'abc123def456789012345678901234567890abcdef' }
350 }
351
352 $result = Get-LatestCommitSHA -Owner 'actions' -Repo 'checkout' -Branch 'main'
353
354 $result | Should -Be 'abc123def456789012345678901234567890abcdef'
355 Should -Invoke Test-GitHubToken -Times 1
356 Should -Invoke Invoke-GitHubAPIWithRetry -Times 1
357 }
358
359 It 'Handles branch parameter with refs/heads prefix' {
360 Mock Test-GitHubToken { return @{ Valid = $true } }
361 Mock Invoke-GitHubAPIWithRetry {
362 param($Uri)
363 if ($Uri -match 'refs/heads/main') {
364 return @{ sha = 'sha123' }
365 }
366 return @{ sha = 'sha456' }
367 }
368
369 $result = Get-LatestCommitSHA -Owner 'actions' -Repo 'checkout' -Branch 'refs/heads/main'
370
371 $result | Should -Be 'sha123'
372 Should -Invoke Test-GitHubToken -Times 1
373 Should -Invoke Invoke-GitHubAPIWithRetry -Times 1
374 }
375 }
376
377 Context 'Error handling' {
378 It 'Returns null for non-existent repository' {
379 Mock Test-GitHubToken { return @{ Valid = $true } }
380 Mock Invoke-GitHubAPIWithRetry { return $null }
381
382 $result = Get-LatestCommitSHA -Owner 'nonexistent' -Repo 'repo' -Branch 'main'
383
384 $result | Should -BeNullOrEmpty
385 Should -Invoke Test-GitHubToken -Times 1
386 Should -Invoke Invoke-GitHubAPIWithRetry -Times 1
387 }
388
389 It 'Returns null on API error without throwing' {
390 Mock Test-GitHubToken { return @{ Valid = $true } }
391 Mock Invoke-GitHubAPIWithRetry { return $null }
392
393 # Function should handle error gracefully and return null
394 $result = Get-LatestCommitSHA -Owner 'actions' -Repo 'checkout' -Branch 'main'
395 $result | Should -BeNullOrEmpty
396 Should -Invoke Test-GitHubToken -Times 1
397 Should -Invoke Invoke-GitHubAPIWithRetry -Times 1
398 }
399 }
400
401 Context 'Default branch detection' {
402 It 'Uses default branch when Branch not specified' {
403 Mock Test-GitHubToken { return @{ Valid = $true } }
404 Mock Invoke-GitHubAPIWithRetry {
405 param($Uri)
406 if ($Uri -match '/repos/[^/]+/[^/]+$') {
407 return @{ default_branch = 'main' }
408 }
409 return @{ sha = 'default-branch-sha' }
410 }
411
412 $result = Get-LatestCommitSHA -Owner 'actions' -Repo 'checkout'
413
414 $result | Should -Not -BeNullOrEmpty
415 Should -Invoke Test-GitHubToken -Times 1
416 Should -Invoke Invoke-GitHubAPIWithRetry -Times 2
417 }
418 }
419}
420
421Describe 'Test-GitHubToken' -Tag 'Unit' {
422 BeforeEach {
423 Initialize-MockCIEnvironment
424 }
425
426 AfterEach {
427 Clear-MockCIEnvironment
428 }
429
430 Context 'Valid authenticated token' {
431 It 'Returns Valid and Authenticated for good token' {
432 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
433 return (script:New-MockGitHubGraphQLResponse -Login 'testuser' -RateRemaining 5000)
434 }
435
436 $result = Test-GitHubToken -Token 'ghp_validtoken123'
437
438 $result.Valid | Should -BeTrue
439 $result.Authenticated | Should -BeTrue
440 $result.User | Should -Be 'testuser'
441 }
442
443 It 'Returns rate limit information' {
444 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
445 return (script:New-MockGitHubGraphQLResponse -RateRemaining 4500 -RateLimit 5000)
446 }
447
448 $result = Test-GitHubToken -Token 'ghp_validtoken123'
449
450 $result.Remaining | Should -Be 4500
451 $result.RateLimit | Should -Be 5000
452 }
453 }
454
455 Context 'Unauthenticated access' {
456 It 'Returns Valid false for empty token' {
457 $result = Test-GitHubToken -Token ''
458
459 $result.Valid | Should -BeFalse
460 $result.Authenticated | Should -BeFalse
461 $result.Message | Should -Be 'Token is empty or null'
462 }
463 }
464
465 Context 'Low rate limit warning' {
466 It 'Includes warning when remaining is low' {
467 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
468 return (script:New-MockGitHubGraphQLResponse -RateRemaining 50 -RateLimit 5000)
469 }
470
471 $result = Test-GitHubToken -Token 'ghp_validtoken123'
472
473 $result.Remaining | Should -BeLessThan 100
474 }
475 }
476
477 Context 'Invalid token' {
478 It 'Returns Valid false on API error' {
479 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
480 throw [System.Net.WebException]::new('Unauthorized')
481 }
482
483 $result = Test-GitHubToken -Token 'invalid_token'
484
485 $result.Valid | Should -BeFalse
486 }
487
488 It 'Includes error message on failure' {
489 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
490 throw [System.Exception]::new('Bad credentials')
491 }
492
493 $result = Test-GitHubToken -Token 'bad_token'
494
495 $result.Message | Should -Not -BeNullOrEmpty
496 }
497 }
498}
499
500Describe 'Export-SecurityReport' -Tag 'Unit' {
501 BeforeAll {
502 $script:MockResults = @(
503 @{
504 FilePath = 'workflow1.yml'
505 ActionsPinned = 3
506 ActionsSkipped = 1
507 Changes = @(
508 @{ Action = 'actions/checkout@v4'; Status = 'Pinned' }
509 )
510 }
511 )
512 }
513
514 Context 'Report generation' {
515 It 'Creates report file' {
516 Mock New-Item { param($Path) return @{ FullName = $Path } }
517 Mock Set-Content { }
518 Mock Get-Date { return [datetime]'2026-01-26T10:00:00' }
519
520 $result = Export-SecurityReport -Results $script:MockResults
521
522 $result | Should -Not -BeNullOrEmpty
523 }
524
525 It 'Returns report file path' {
526 Mock New-Item { param($Path) return @{ FullName = $Path } }
527 Mock Set-Content { }
528
529 $result = Export-SecurityReport -Results $script:MockResults
530
531 $result | Should -Match '\.json$'
532 }
533 }
534
535 Context 'Empty results handling' {
536 It 'Rejects empty results array via parameter validation' {
537 { Export-SecurityReport -Results @() } | Should -Throw '*empty collection*'
538 }
539 }
540}
541
542Describe 'Set-ContentPreservePermission' -Tag 'Unit' {
543 Context 'File writing' {
544 It 'Writes content to file' {
545 $testPath = Join-Path $TestDrive 'test-write.txt'
546
547 Set-ContentPreservePermission -Path $testPath -Value 'test content'
548
549 Test-Path $testPath | Should -BeTrue
550 Get-Content $testPath -Raw | Should -Match 'test content'
551 }
552
553 It 'Respects NoNewline parameter' {
554 $testPath = Join-Path $TestDrive 'test-nonewline.txt'
555
556 Set-ContentPreservePermission -Path $testPath -Value 'no newline' -NoNewline
557
558 $content = [System.IO.File]::ReadAllText($testPath)
559 $content | Should -Be 'no newline'
560 }
561 }
562
563 Context 'Permission preservation' {
564 It 'Does not throw on Windows' {
565 $testPath = Join-Path $TestDrive 'test-perm.txt'
566
567 { Set-ContentPreservePermission -Path $testPath -Value 'content' } |
568 Should -Not -Throw
569 }
570 }
571
572 Context 'Overwrite behavior' {
573 It 'Overwrites existing file content' {
574 $testPath = Join-Path $TestDrive 'test-overwrite.txt'
575 Set-Content $testPath -Value 'original'
576
577 Set-ContentPreservePermission -Path $testPath -Value 'updated'
578
579 Get-Content $testPath -Raw | Should -Match 'updated'
580 }
581 }
582}
583
584Describe 'Get-SHAForAction - Already Pinned' -Tag 'Unit' {
585 BeforeAll {
586 $script:OriginalGitHubToken = $env:GITHUB_TOKEN
587 $env:GITHUB_TOKEN = 'ghp_test123456789'
588 }
589
590 AfterAll {
591 $env:GITHUB_TOKEN = $script:OriginalGitHubToken
592 }
593
594 Context 'SHA-pinned action without UpdateStale' {
595 It 'Returns original ref when action is already SHA-pinned' {
596 $sha = 'a' * 40
597 $ref = "actions/checkout@$sha"
598 Mock Write-SecurityLog { }
599
600 $result = Get-SHAForAction -ActionRef $ref
601
602 $result | Should -Be $ref
603 }
604 }
605
606 Context 'SHA-pinned action with UpdateStale' {
607 It 'Returns original ref when UpdateStale is not specified' {
608 $currentSHA = 'a' * 40
609 $latestSHA = 'b' * 40
610 $ref = "actions/checkout@$currentSHA"
611
612 Mock Write-SecurityLog { }
613 Mock Get-LatestCommitSHA { return $latestSHA }
614
615 $result = Get-SHAForAction -ActionRef $ref
616
617 # Without UpdateStale flag in scope, returns original
618 $result | Should -Be $ref
619 }
620 }
621}
622
623Describe 'Update-WorkflowFile - Edge Cases' -Tag 'Unit' {
624 Context 'No actions in file' {
625 It 'Returns zero counts when file has no action references' {
626 $testFile = Join-Path $TestDrive 'empty-workflow.yml'
627 Set-Content $testFile -Value @'
628name: empty
629on: push
630jobs:
631 build:
632 runs-on: ubuntu-latest
633 steps:
634 - run: echo hello
635'@
636 Mock Write-SecurityLog { }
637
638 $result = Update-WorkflowFile -FilePath $testFile
639
640 $result.ActionsProcessed | Should -Be 0
641 $result.ActionsPinned | Should -Be 0
642 $result.ActionsSkipped | Should -Be 0
643 }
644 }
645
646 Context 'File with local actions' {
647 It 'Skips local action references starting with ./' {
648 $testFile = Join-Path $TestDrive 'local-action.yml'
649 Set-Content $testFile -Value @'
650name: local
651on: push
652jobs:
653 build:
654 runs-on: ubuntu-latest
655 steps:
656 - uses: ./local-action
657'@
658 Mock Write-SecurityLog { }
659
660 $result = Update-WorkflowFile -FilePath $testFile
661
662 $result.ActionsProcessed | Should -Be 0
663 }
664 }
665}
666
667Describe 'Invoke-ActionSHAPinningUpdate' -Tag 'Unit' {
668 BeforeAll {
669 $env:GITHUB_TOKEN = 'ghp_test123456789'
670 Initialize-MockCIEnvironment
671 }
672 AfterAll {
673 Clear-MockCIEnvironment
674 }
675
676 Context 'Missing workflow path' {
677 It 'Throws when workflow path does not exist' {
678 { Invoke-ActionSHAPinningUpdate -WorkflowPath '/nonexistent/path' } |
679 Should -Throw '*Workflow path not found*'
680 }
681 }
682
683 Context 'No YAML files in directory' {
684 It 'Warns and returns when no yml files found' {
685 $emptyDir = Join-Path $TestDrive 'empty-workflows'
686 New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null
687
688 Mock Write-SecurityLog { }
689
690 Invoke-ActionSHAPinningUpdate -WorkflowPath $emptyDir
691
692 Should -Invoke Write-SecurityLog -Times 1 -ParameterFilter { $Level -eq 'Warning' }
693 }
694 }
695
696 Context 'Full orchestration' {
697 It 'Processes workflow files and generates summary' {
698 $workDir = Join-Path $TestDrive 'orchestration-workflows'
699 New-Item -ItemType Directory -Path $workDir -Force | Out-Null
700
701 $sha = 'a' * 40
702 $content = @"
703name: test
704on: push
705jobs:
706 build:
707 runs-on: ubuntu-latest
708 steps:
709 - uses: actions/checkout@$sha
710"@
711 Set-Content (Join-Path $workDir 'ci.yml') -Value $content
712
713 Mock Write-SecurityLog { }
714 Mock Write-SecurityOutput { }
715 Mock Get-SHAForAction { return "actions/checkout@$sha" }
716
717 Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputFormat 'console'
718
719 Should -Invoke Write-SecurityOutput -Times 1
720 }
721 }
722
723 Context 'OutputReport flag' {
724 It 'Calls Export-SecurityReport when OutputReport is set' {
725 $workDir = Join-Path $TestDrive 'report-workflows'
726 New-Item -ItemType Directory -Path $workDir -Force | Out-Null
727
728 Set-Content (Join-Path $workDir 'test.yml') -Value @'
729name: test
730on: push
731jobs:
732 build:
733 runs-on: ubuntu-latest
734 steps:
735 - run: echo hi
736'@
737 Mock Write-SecurityLog { }
738 Mock Write-SecurityOutput { }
739 Mock Export-SecurityReport { return (Join-Path $TestDrive 'report.json') }
740
741 Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputReport -OutputFormat 'console'
742
743 Should -Invoke Export-SecurityReport -Times 1
744 }
745 }
746
747 Context 'Manual review actions' {
748 It 'Adds SecurityIssue for actions requiring manual review' {
749 $workDir = Join-Path $TestDrive 'manual-review-workflows'
750 New-Item -ItemType Directory -Path $workDir -Force | Out-Null
751
752 Set-Content (Join-Path $workDir 'unmapped.yml') -Value @'
753name: unmapped
754on: push
755jobs:
756 build:
757 runs-on: ubuntu-latest
758 steps:
759 - name: Unknown action
760 uses: some-unknown/action@v1
761'@
762 Mock Write-SecurityLog { }
763 Mock Write-SecurityOutput { }
764 Mock Get-SHAForAction { return $null }
765 Mock New-SecurityIssue { return [PSCustomObject]@{Type='';Severity='';Title='';Description=''} }
766
767 Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputFormat 'console'
768
769 Should -Invoke New-SecurityIssue -Times 1
770 }
771 }
772
773 Context 'WhatIf support' {
774 It 'Does not modify files when WhatIf is used' {
775 $workDir = Join-Path $TestDrive 'whatif-workflows'
776 New-Item -ItemType Directory -Path $workDir -Force | Out-Null
777
778 $sha = 'a' * 40
779 $content = @"
780name: whatif
781on: push
782jobs:
783 build:
784 runs-on: ubuntu-latest
785 steps:
786 - uses: actions/checkout@$sha
787"@
788 $filePath = Join-Path $workDir 'whatif.yml'
789 Set-Content $filePath -Value $content
790
791 Mock Write-SecurityLog { }
792 Mock Write-SecurityOutput { }
793 Mock Get-SHAForAction { return "actions/checkout@$sha" }
794
795 Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputFormat 'console' -WhatIf
796
797 # File content should remain unchanged
798 $afterContent = Get-Content $filePath -Raw
799 $afterContent | Should -Match "actions/checkout@$sha"
800 }
801 }
802}
803