microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hve-core-v2.0.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

815lines · modecode

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