microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
docs/621-ai-artifacts

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

1007lines · 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 Invoke-RestMethod {
136 return @{
137 object = @{
138 sha = 'newsha123456789012345678901234567890abcd'
139 }
140 }
141 }
142 }
143
144 AfterEach {
145 Clear-MockCIEnvironment
146 }
147
148 Context 'Return value structure' {
149 It 'Returns PSCustomObject with FilePath' {
150 $result = Update-WorkflowFile -FilePath $script:TestWorkflow
151 $result | Should -BeOfType [PSCustomObject]
152 $result.FilePath | Should -Be $script:TestWorkflow
153 }
154
155 It 'Returns ActionsProcessed count' {
156 $result = Update-WorkflowFile -FilePath $script:TestWorkflow
157 $result.ActionsProcessed | Should -BeGreaterOrEqual 0
158 }
159
160 It 'Returns ActionsPinned count' {
161 $result = Update-WorkflowFile -FilePath $script:TestWorkflow
162 $result.PSObject.Properties.Name -contains 'ActionsPinned' | Should -BeTrue
163 }
164 }
165
166 Context 'File modification' {
167 It 'Updates unpinned action to SHA' {
168 Update-WorkflowFile -FilePath $script:TestWorkflow
169
170 $content = Get-Content $script:TestWorkflow -Raw
171 # Check that the file was processed (content may or may not change based on mock)
172 $content | Should -Not -BeNullOrEmpty
173 }
174 }
175
176 Context 'Already pinned workflows' {
177 It 'Does not modify already pinned actions' {
178 $pinnedSource = Join-Path $script:FixturesPath 'pinned-workflow.yml'
179 $pinnedTest = Join-Path $TestDrive 'pinned-test.yml'
180 Copy-Item $pinnedSource $pinnedTest
181
182 $originalContent = Get-Content $pinnedTest -Raw
183 Update-WorkflowFile -FilePath $pinnedTest
184 $newContent = Get-Content $pinnedTest -Raw
185
186 $newContent | Should -Be $originalContent
187 }
188 }
189}
190
191Describe 'Update-WorkflowFile -WhatIf' -Tag 'Unit' {
192 BeforeEach {
193 Initialize-MockCIEnvironment
194 $env:GITHUB_TOKEN = 'ghp_test123456789'
195
196 $unpinnedSource = Join-Path $script:FixturesPath 'unpinned-workflow.yml'
197 $script:TestWorkflow = Join-Path $TestDrive 'whatif-test.yml'
198 Copy-Item $unpinnedSource $script:TestWorkflow
199
200 Mock Invoke-RestMethod {
201 return @{
202 object = @{
203 sha = 'newsha123456789012345678901234567890abcd'
204 }
205 }
206 }
207 }
208
209 AfterEach {
210 Clear-MockCIEnvironment
211 }
212
213 Context 'WhatIf behavior' {
214 It 'Does not modify file when WhatIf is specified' {
215 $originalContent = Get-Content $script:TestWorkflow -Raw
216
217 Update-WorkflowFile -FilePath $script:TestWorkflow -WhatIf
218
219 $newContent = Get-Content $script:TestWorkflow -Raw
220 $newContent | Should -Be $originalContent
221 }
222 }
223}
224
225Describe 'Invoke-GitHubAPIWithRetry' -Tag 'Unit' {
226 BeforeEach {
227 Initialize-MockCIEnvironment
228 $env:GITHUB_TOKEN = 'ghp_test123456789'
229 $script:AttemptCount = 0
230
231 # Mock Start-Sleep to avoid actual delays
232 Mock Start-Sleep { }
233 }
234
235 AfterEach {
236 Clear-MockCIEnvironment
237 }
238
239 Context 'Successful requests' {
240 It 'Returns response on first attempt success' {
241 Mock Invoke-RestMethod {
242 $script:AttemptCount++
243 return @{ data = 'success' }
244 }
245
246 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'GET' -Headers @{ Authorization = 'token test' }
247
248 $result.data | Should -Be 'success'
249 $script:AttemptCount | Should -Be 1
250 Should -Not -Invoke Start-Sleep
251 }
252 }
253
254 Context 'Rate limit retry behavior' {
255 It 'Retries on 403 rate limit error and succeeds' {
256 Mock Invoke-RestMethod {
257 $script:AttemptCount++
258 if ($script:AttemptCount -lt 3) {
259 # Create exception with proper Response.StatusCode for rate limit detection
260 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Forbidden)
261 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('API rate limit exceeded', $response)
262 throw $exception
263 }
264 return @{ data = 'success after retry' }
265 }
266
267 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'GET' -Headers @{ Authorization = 'token test' } -MaxRetries 5
268
269 $result.data | Should -Be 'success after retry'
270 $script:AttemptCount | Should -Be 3
271 Should -Invoke Start-Sleep -Times 2
272 }
273
274 It 'Throws after exceeding MaxRetries' {
275 Mock Invoke-RestMethod {
276 $script:AttemptCount++
277 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Forbidden)
278 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('API rate limit exceeded', $response)
279 throw $exception
280 }
281
282 { Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'GET' -Headers @{ Authorization = 'token test' } -MaxRetries 2 } |
283 Should -Throw
284
285 $script:AttemptCount | Should -Be 2 # MaxRetries attempts
286 }
287
288 It 'Uses exponential backoff delay' {
289 $script:delays = @()
290 Mock Start-Sleep { param($Seconds) $script:delays += $Seconds }
291 Mock Invoke-RestMethod {
292 $script:AttemptCount++
293 if ($script:AttemptCount -lt 3) {
294 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Forbidden)
295 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('API rate limit exceeded', $response)
296 throw $exception
297 }
298 return @{ data = 'success' }
299 }
300
301 Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'GET' -Headers @{ Authorization = 'token test' } -InitialDelaySeconds 2
302
303 # Verify exponential backoff pattern
304 $script:delays[0] | Should -Be 2 # First delay
305 $script:delays[1] | Should -Be 4 # Second delay (doubled)
306 }
307 }
308
309 Context 'Non-retryable errors' {
310 It 'Throws immediately on non-rate-limit error' {
311 Mock Invoke-RestMethod {
312 $script:AttemptCount++
313 throw [System.Net.WebException]::new('Not Found')
314 }
315
316 { Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'GET' -Headers @{ Authorization = 'token test' } } |
317 Should -Throw '*Not Found*'
318
319 $script:AttemptCount | Should -Be 1
320 Should -Not -Invoke Start-Sleep
321 }
322 }
323
324 Context 'Request with body' {
325 It 'Includes body in request' {
326 Mock Invoke-RestMethod {
327 param($Uri, $Method, $Headers, $Body, $ContentType)
328 $null = $Uri, $Method, $Headers # Suppress PSScriptAnalyzer unused parameter warnings
329 return @{ received = $Body; contentType = $ContentType }
330 }
331
332 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/graphql' -Method 'POST' -Headers @{ Authorization = 'token test' } -Body '{"query":"test"}'
333
334 $result.received | Should -Be '{"query":"test"}'
335 $result.contentType | Should -Be 'application/json'
336 }
337 }
338}
339
340Describe 'Write-OutputResult' -Tag 'Unit' {
341 BeforeAll {
342 $script:TestResults = @(
343 @{
344 FilePath = 'test.yml'
345 ActionsPinned = 2
346 ActionsSkipped = 1
347 Changes = @(
348 @{ Action = 'actions/checkout@v4'; Status = 'Pinned'; NewRef = 'actions/checkout@abc123' }
349 )
350 }
351 )
352 $script:TestSummary = 'Processed 1 file, pinned 2 actions'
353 }
354
355 Context 'JSON output format' {
356 It 'Creates valid JSON output' {
357 $tempPath = Join-Path $TestDrive 'output.json'
358
359 Write-OutputResult -OutputFormat 'json' -Results $script:TestResults -Summary $script:TestSummary -OutputPath $tempPath
360
361 Test-Path $tempPath | Should -BeTrue
362 $content = Get-Content $tempPath -Raw
363 { $content | ConvertFrom-Json } | Should -Not -Throw
364 }
365
366 It 'Includes results in JSON structure' {
367 $tempPath = Join-Path $TestDrive 'results.json'
368
369 Write-OutputResult -OutputFormat 'json' -Results $script:TestResults -Summary $script:TestSummary -OutputPath $tempPath
370
371 $json = Get-Content $tempPath -Raw | ConvertFrom-Json
372 $json | Should -Not -BeNullOrEmpty
373 }
374 }
375
376 Context 'AzDO output format' {
377 It 'Emits VSO logging commands' {
378 $script:TestIssueResults = @(
379 @{
380 Severity = 'High'
381 Title = 'Test Issue'
382 Description = 'Test description'
383 File = 'workflow.yml'
384 }
385 )
386
387 $output = Write-OutputResult -OutputFormat 'azdo' -Results $script:TestIssueResults -Summary 'Test'
388
389 # Function uses Write-Output for VSO commands
390 $hasVsoCommand = $output | Where-Object { $_ -match '##vso\[' }
391 $hasVsoCommand | Should -Not -BeNullOrEmpty
392 }
393 }
394
395 Context 'GitHub output format' {
396 It 'Emits GitHub Actions workflow commands' {
397 $script:TestIssueResults = @(
398 @{
399 Severity = 'High'
400 Title = 'Test Issue'
401 Description = 'Test description'
402 File = 'workflow.yml'
403 }
404 )
405
406 $output = Write-OutputResult -OutputFormat 'github' -Results $script:TestIssueResults -Summary 'Test'
407
408 # Function uses Write-Output for GitHub commands
409 $hasGitHubCommand = $output | Where-Object { $_ -match '^::\w+' }
410 $hasGitHubCommand | Should -Not -BeNullOrEmpty
411 }
412 }
413
414 Context 'Console output format' {
415 It 'Writes summary to console' {
416 # Console format reads from $script:SecurityIssues, so populate it
417 $script:SecurityIssues = @(
418 @{
419 Title = 'Test Issue'
420 Description = 'Test description'
421 }
422 )
423 Mock Write-Host { }
424
425 # Should not throw
426 { Write-OutputResult -OutputFormat 'console' -Results $script:TestResults -Summary $script:TestSummary } |
427 Should -Not -Throw
428 }
429 }
430
431 Context 'BuildWarning output format' {
432 It 'Emits build warning format' {
433 $script:TestIssueResults = @(
434 @{
435 Title = 'Test Issue'
436 Description = 'Test description'
437 File = 'workflow.yml'
438 }
439 )
440
441 $output = Write-OutputResult -OutputFormat 'BuildWarning' -Results $script:TestIssueResults -Summary 'Test'
442
443 # Function uses Write-Output for build warnings
444 $output | Should -Not -BeNullOrEmpty
445 ($output | Where-Object { $_ -match '##\[warning\]' }) | Should -Not -BeNullOrEmpty
446 }
447 }
448
449 Context 'Empty results handling' {
450 It 'Handles empty results array' {
451 { Write-OutputResult -OutputFormat 'console' -Results @() -Summary 'No files processed' } |
452 Should -Not -Throw
453 }
454 }
455}
456
457Describe 'Get-LatestCommitSHA' -Tag 'Unit' {
458 BeforeEach {
459 Initialize-MockCIEnvironment
460 $env:GITHUB_TOKEN = 'ghp_test123456789'
461 }
462
463 AfterEach {
464 Clear-MockCIEnvironment
465 }
466
467 Context 'Successful SHA retrieval' {
468 It 'Returns SHA for valid repository and branch' {
469 Mock Invoke-RestMethod {
470 return @{ sha = 'abc123def456789012345678901234567890abcdef' }
471 }
472
473 $result = Get-LatestCommitSHA -Owner 'actions' -Repo 'checkout' -Branch 'main'
474
475 $result | Should -Be 'abc123def456789012345678901234567890abcdef'
476 }
477
478 It 'Handles branch parameter with refs/heads prefix' {
479 Mock Invoke-RestMethod {
480 param($Uri)
481 if ($Uri -match 'refs/heads/main') {
482 return @{ sha = 'sha123' }
483 }
484 return @{ sha = 'sha456' }
485 }
486
487 $result = Get-LatestCommitSHA -Owner 'actions' -Repo 'checkout' -Branch 'refs/heads/main'
488
489 $result | Should -Be 'sha123'
490 }
491 }
492
493 Context 'Error handling' {
494 It 'Returns null for non-existent repository' {
495 Mock Invoke-RestMethod {
496 throw [System.Net.WebException]::new('Not Found')
497 }
498
499 $result = Get-LatestCommitSHA -Owner 'nonexistent' -Repo 'repo' -Branch 'main'
500
501 $result | Should -BeNullOrEmpty
502 }
503
504 It 'Returns null on API error without throwing' {
505 Mock Invoke-RestMethod {
506 throw [System.Exception]::new('Network error')
507 }
508
509 # Function should handle error gracefully and return null
510 $result = Get-LatestCommitSHA -Owner 'actions' -Repo 'checkout' -Branch 'main'
511 $result | Should -BeNullOrEmpty
512 }
513 }
514
515 Context 'Default branch detection' {
516 It 'Uses default branch when Branch not specified' {
517 Mock Invoke-RestMethod {
518 param($Uri)
519 if ($Uri -match '/repos/[^/]+/[^/]+$') {
520 return @{ default_branch = 'main' }
521 }
522 return @{ sha = 'default-branch-sha' }
523 }
524
525 $result = Get-LatestCommitSHA -Owner 'actions' -Repo 'checkout'
526
527 $result | Should -Not -BeNullOrEmpty
528 }
529 }
530}
531
532Describe 'Test-GitHubToken' -Tag 'Unit' {
533 BeforeEach {
534 Initialize-MockCIEnvironment
535 }
536
537 AfterEach {
538 Clear-MockCIEnvironment
539 }
540
541 Context 'Valid authenticated token' {
542 It 'Returns Valid and Authenticated for good token' {
543 Mock Invoke-RestMethod {
544 return (script:New-MockGitHubGraphQLResponse -Login 'testuser' -RateRemaining 5000)
545 }
546
547 $result = Test-GitHubToken -Token 'ghp_validtoken123'
548
549 $result.Valid | Should -BeTrue
550 $result.Authenticated | Should -BeTrue
551 $result.User | Should -Be 'testuser'
552 }
553
554 It 'Returns rate limit information' {
555 Mock Invoke-RestMethod {
556 return (script:New-MockGitHubGraphQLResponse -RateRemaining 4500 -RateLimit 5000)
557 }
558
559 $result = Test-GitHubToken -Token 'ghp_validtoken123'
560
561 $result.Remaining | Should -Be 4500
562 $result.RateLimit | Should -Be 5000
563 }
564 }
565
566 Context 'Unauthenticated access' {
567 It 'Returns Valid but not Authenticated for empty token' {
568 Mock Invoke-RestMethod {
569 return @{
570 data = @{
571 rateLimit = @{ remaining = 60; limit = 60 }
572 }
573 }
574 }
575
576 $result = Test-GitHubToken -Token ''
577
578 $result.Valid | Should -BeTrue
579 $result.Authenticated | Should -BeFalse
580 }
581 }
582
583 Context 'Low rate limit warning' {
584 It 'Includes warning when remaining is low' {
585 Mock Invoke-RestMethod {
586 return (script:New-MockGitHubGraphQLResponse -RateRemaining 50 -RateLimit 5000)
587 }
588
589 $result = Test-GitHubToken -Token 'ghp_validtoken123'
590
591 $result.Remaining | Should -BeLessThan 100
592 }
593 }
594
595 Context 'Invalid token' {
596 It 'Returns Valid false on API error' {
597 Mock Invoke-RestMethod {
598 throw [System.Net.WebException]::new('Unauthorized')
599 }
600
601 $result = Test-GitHubToken -Token 'invalid_token'
602
603 $result.Valid | Should -BeFalse
604 }
605
606 It 'Includes error message on failure' {
607 Mock Invoke-RestMethod {
608 throw [System.Exception]::new('Bad credentials')
609 }
610
611 $result = Test-GitHubToken -Token 'bad_token'
612
613 $result.Message | Should -Not -BeNullOrEmpty
614 }
615 }
616}
617
618Describe 'Export-SecurityReport' -Tag 'Unit' {
619 BeforeAll {
620 $script:MockResults = @(
621 @{
622 FilePath = 'workflow1.yml'
623 ActionsPinned = 3
624 ActionsSkipped = 1
625 Changes = @(
626 @{ Action = 'actions/checkout@v4'; Status = 'Pinned' }
627 )
628 }
629 )
630 }
631
632 Context 'Report generation' {
633 It 'Creates report file' {
634 Mock New-Item { param($Path) return @{ FullName = $Path } }
635 Mock Set-Content { }
636 Mock Get-Date { return [datetime]'2026-01-26T10:00:00' }
637
638 $result = Export-SecurityReport -Results $script:MockResults
639
640 $result | Should -Not -BeNullOrEmpty
641 }
642
643 It 'Returns report file path' {
644 Mock New-Item { param($Path) return @{ FullName = $Path } }
645 Mock Set-Content { }
646
647 $result = Export-SecurityReport -Results $script:MockResults
648
649 $result | Should -Match '\.json$'
650 }
651 }
652
653 Context 'Empty results handling' {
654 It 'Rejects empty results array via parameter validation' {
655 { Export-SecurityReport -Results @() } | Should -Throw '*empty collection*'
656 }
657 }
658}
659
660Describe 'Set-ContentPreservePermission' -Tag 'Unit' {
661 Context 'File writing' {
662 It 'Writes content to file' {
663 $testPath = Join-Path $TestDrive 'test-write.txt'
664
665 Set-ContentPreservePermission -Path $testPath -Value 'test content'
666
667 Test-Path $testPath | Should -BeTrue
668 Get-Content $testPath -Raw | Should -Match 'test content'
669 }
670
671 It 'Respects NoNewline parameter' {
672 $testPath = Join-Path $TestDrive 'test-nonewline.txt'
673
674 Set-ContentPreservePermission -Path $testPath -Value 'no newline' -NoNewline
675
676 $content = [System.IO.File]::ReadAllText($testPath)
677 $content | Should -Be 'no newline'
678 }
679 }
680
681 Context 'Permission preservation' {
682 It 'Does not throw on Windows' {
683 $testPath = Join-Path $TestDrive 'test-perm.txt'
684
685 { Set-ContentPreservePermission -Path $testPath -Value 'content' } |
686 Should -Not -Throw
687 }
688 }
689
690 Context 'Overwrite behavior' {
691 It 'Overwrites existing file content' {
692 $testPath = Join-Path $TestDrive 'test-overwrite.txt'
693 Set-Content $testPath -Value 'original'
694
695 Set-ContentPreservePermission -Path $testPath -Value 'updated'
696
697 Get-Content $testPath -Raw | Should -Match 'updated'
698 }
699 }
700}
701
702Describe 'Add-SecurityIssue' -Tag 'Unit' {
703 BeforeEach {
704 # Reset script-level variable
705 $script:SecurityIssues = @()
706 }
707
708 Context 'Issue accumulation' {
709 It 'Adds issue to SecurityIssues array' {
710 Add-SecurityIssue -Type 'UnpinnedAction' -Severity 'High' -Title 'Test Issue' -Description 'Test description'
711
712 $script:SecurityIssues | Should -HaveCount 1
713 }
714
715 It 'Accumulates multiple issues' {
716 Add-SecurityIssue -Type 'UnpinnedAction' -Severity 'High' -Title 'Issue 1' -Description 'Desc 1'
717 Add-SecurityIssue -Type 'StaleAction' -Severity 'Medium' -Title 'Issue 2' -Description 'Desc 2'
718
719 $script:SecurityIssues | Should -HaveCount 2
720 }
721 }
722
723 Context 'Issue structure' {
724 It 'Includes all required fields' {
725 Add-SecurityIssue -Type 'UnpinnedAction' -Severity 'Critical' -Title 'Critical Issue' -Description 'Critical description'
726
727 $issue = $script:SecurityIssues[0]
728 $issue.Type | Should -Be 'UnpinnedAction'
729 $issue.Severity | Should -Be 'Critical'
730 $issue.Title | Should -Be 'Critical Issue'
731 $issue.Description | Should -Be 'Critical description'
732 }
733
734 It 'Includes optional fields when provided' {
735 Add-SecurityIssue -Type 'UnpinnedAction' -Severity 'High' -Title 'Issue' -Description 'Desc' -File 'workflow.yml' -Line '10' -Recommendation 'Pin the action'
736
737 $issue = $script:SecurityIssues[0]
738 $issue.File | Should -Be 'workflow.yml'
739 $issue.Line | Should -Be '10'
740 $issue.Recommendation | Should -Be 'Pin the action'
741 }
742 }
743}
744
745Describe 'Write-SecurityLog' -Tag 'Unit' {
746 Context 'Log levels' {
747 It 'Writes Info level messages' {
748 Mock Write-Host { } -Verifiable
749
750 Write-SecurityLog -Message 'Info message' -Level 'Info'
751
752 Should -InvokeVerifiable
753 }
754
755 It 'Writes Warning level messages with Warning prefix' {
756 # Write-SecurityLog uses Write-Host for all levels with a prefix
757 $captured = $null
758 Mock Write-Host { param($Object) $script:captured = $Object }
759
760 Write-SecurityLog -Message 'Warning message' -Level 'Warning'
761
762 $script:captured | Should -Match '\[Warning\]'
763 $script:captured | Should -Match 'Warning message'
764 }
765
766 It 'Writes Error level messages with Error prefix' {
767 # Write-SecurityLog uses Write-Host for all levels with a prefix
768 $captured = $null
769 Mock Write-Host { param($Object) $script:captured = $Object }
770
771 Write-SecurityLog -Message 'Error message' -Level 'Error'
772
773 $script:captured | Should -Match '\[Error\]'
774 $script:captured | Should -Match 'Error message'
775 }
776 }
777
778 Context 'Default level' {
779 It 'Uses Info as default level' {
780 Mock Write-Host { } -Verifiable
781
782 Write-SecurityLog -Message 'Default level message'
783
784 Should -InvokeVerifiable
785 }
786 }
787}
788
789Describe 'Get-SHAForAction - Already Pinned' -Tag 'Unit' {
790 BeforeAll {
791 $script:OriginalGitHubToken = $env:GITHUB_TOKEN
792 $env:GITHUB_TOKEN = 'ghp_test123456789'
793 }
794
795 AfterAll {
796 $env:GITHUB_TOKEN = $script:OriginalGitHubToken
797 }
798
799 Context 'SHA-pinned action without UpdateStale' {
800 It 'Returns original ref when action is already SHA-pinned' {
801 $sha = 'a' * 40
802 $ref = "actions/checkout@$sha"
803 Mock Write-SecurityLog { }
804
805 $result = Get-SHAForAction -ActionRef $ref
806
807 $result | Should -Be $ref
808 }
809 }
810
811 Context 'SHA-pinned action with UpdateStale' {
812 It 'Returns original ref when UpdateStale is not specified' {
813 $currentSHA = 'a' * 40
814 $latestSHA = 'b' * 40
815 $ref = "actions/checkout@$currentSHA"
816
817 Mock Write-SecurityLog { }
818 Mock Get-LatestCommitSHA { return $latestSHA }
819
820 $result = Get-SHAForAction -ActionRef $ref
821
822 # Without UpdateStale flag in scope, returns original
823 $result | Should -Be $ref
824 }
825 }
826}
827
828Describe 'Update-WorkflowFile - Edge Cases' -Tag 'Unit' {
829 Context 'No actions in file' {
830 It 'Returns zero counts when file has no action references' {
831 $testFile = Join-Path $TestDrive 'empty-workflow.yml'
832 Set-Content $testFile -Value @'
833name: empty
834on: push
835jobs:
836 build:
837 runs-on: ubuntu-latest
838 steps:
839 - run: echo hello
840'@
841 Mock Write-SecurityLog { }
842
843 $result = Update-WorkflowFile -FilePath $testFile
844
845 $result.ActionsProcessed | Should -Be 0
846 $result.ActionsPinned | Should -Be 0
847 $result.ActionsSkipped | Should -Be 0
848 }
849 }
850
851 Context 'File with local actions' {
852 It 'Skips local action references starting with ./' {
853 $testFile = Join-Path $TestDrive 'local-action.yml'
854 Set-Content $testFile -Value @'
855name: local
856on: push
857jobs:
858 build:
859 runs-on: ubuntu-latest
860 steps:
861 - uses: ./local-action
862'@
863 Mock Write-SecurityLog { }
864
865 $result = Update-WorkflowFile -FilePath $testFile
866
867 $result.ActionsProcessed | Should -Be 0
868 }
869 }
870}
871
872Describe 'Invoke-ActionSHAPinningUpdate' -Tag 'Unit' {
873 BeforeAll {
874 $env:GITHUB_TOKEN = 'ghp_test123456789'
875 Initialize-MockCIEnvironment
876 }
877 AfterAll {
878 Clear-MockCIEnvironment
879 }
880
881 Context 'Missing workflow path' {
882 It 'Throws when workflow path does not exist' {
883 { Invoke-ActionSHAPinningUpdate -WorkflowPath '/nonexistent/path' } |
884 Should -Throw '*Workflow path not found*'
885 }
886 }
887
888 Context 'No YAML files in directory' {
889 It 'Warns and returns when no yml files found' {
890 $emptyDir = Join-Path $TestDrive 'empty-workflows'
891 New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null
892
893 Mock Write-SecurityLog { }
894
895 Invoke-ActionSHAPinningUpdate -WorkflowPath $emptyDir
896
897 Should -Invoke Write-SecurityLog -Times 1 -ParameterFilter { $Level -eq 'Warning' }
898 }
899 }
900
901 Context 'Full orchestration' {
902 It 'Processes workflow files and generates summary' {
903 $workDir = Join-Path $TestDrive 'orchestration-workflows'
904 New-Item -ItemType Directory -Path $workDir -Force | Out-Null
905
906 $sha = 'a' * 40
907 $content = @"
908name: test
909on: push
910jobs:
911 build:
912 runs-on: ubuntu-latest
913 steps:
914 - uses: actions/checkout@$sha
915"@
916 Set-Content (Join-Path $workDir 'ci.yml') -Value $content
917
918 Mock Write-SecurityLog { }
919 Mock Write-OutputResult { }
920 Mock Get-SHAForAction { return "actions/checkout@$sha" }
921
922 Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputFormat 'console'
923
924 Should -Invoke Write-OutputResult -Times 1
925 }
926 }
927
928 Context 'OutputReport flag' {
929 It 'Calls Export-SecurityReport when OutputReport is set' {
930 $workDir = Join-Path $TestDrive 'report-workflows'
931 New-Item -ItemType Directory -Path $workDir -Force | Out-Null
932
933 Set-Content (Join-Path $workDir 'test.yml') -Value @'
934name: test
935on: push
936jobs:
937 build:
938 runs-on: ubuntu-latest
939 steps:
940 - run: echo hi
941'@
942 Mock Write-SecurityLog { }
943 Mock Write-OutputResult { }
944 Mock Export-SecurityReport { return (Join-Path $TestDrive 'report.json') }
945
946 Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputReport -OutputFormat 'console'
947
948 Should -Invoke Export-SecurityReport -Times 1
949 }
950 }
951
952 Context 'Manual review actions' {
953 It 'Adds SecurityIssue for actions requiring manual review' {
954 $workDir = Join-Path $TestDrive 'manual-review-workflows'
955 New-Item -ItemType Directory -Path $workDir -Force | Out-Null
956
957 Set-Content (Join-Path $workDir 'unmapped.yml') -Value @'
958name: unmapped
959on: push
960jobs:
961 build:
962 runs-on: ubuntu-latest
963 steps:
964 - name: Unknown action
965 uses: some-unknown/action@v1
966'@
967 Mock Write-SecurityLog { }
968 Mock Write-OutputResult { }
969 Mock Get-SHAForAction { return $null }
970 Mock Add-SecurityIssue { }
971
972 Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputFormat 'console'
973
974 Should -Invoke Add-SecurityIssue -Times 1
975 }
976 }
977
978 Context 'WhatIf support' {
979 It 'Does not modify files when WhatIf is used' {
980 $workDir = Join-Path $TestDrive 'whatif-workflows'
981 New-Item -ItemType Directory -Path $workDir -Force | Out-Null
982
983 $sha = 'a' * 40
984 $content = @"
985name: whatif
986on: push
987jobs:
988 build:
989 runs-on: ubuntu-latest
990 steps:
991 - uses: actions/checkout@$sha
992"@
993 $filePath = Join-Path $workDir 'whatif.yml'
994 Set-Content $filePath -Value $content
995
996 Mock Write-SecurityLog { }
997 Mock Write-OutputResult { }
998 Mock Get-SHAForAction { return "actions/checkout@$sha" }
999
1000 Invoke-ActionSHAPinningUpdate -WorkflowPath $workDir -OutputFormat 'console' -WhatIf
1001
1002 # File content should remain unchanged
1003 $afterContent = Get-Content $filePath -Raw
1004 $afterContent | Should -Match "actions/checkout@$sha"
1005 }
1006 }
1007}
1008