microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/research-single-dynamic-rewrite

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

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