microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/artifacts-generic

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/tests/security/SecurityHelpers.Tests.ps1

787lines · modecode

1# Copyright (c) Microsoft Corporation.
2# SPDX-License-Identifier: MIT
3# Licensed under the MIT license.
4
5#Requires -Modules Pester
6# SecurityHelpers.Tests.ps1
7#
8# Purpose: Unit tests for SecurityHelpers.psm1 module
9# Author: HVE Core Team
10
11BeforeAll {
12 $modulePath = Join-Path $PSScriptRoot '../../security/Modules/SecurityHelpers.psm1'
13 Import-Module $modulePath -Force
14}
15
16Describe 'Write-SecurityLog' -Tag 'Unit' {
17 Context 'Console output' {
18 It 'Outputs formatted message with timestamp' {
19 $output = Write-SecurityLog -Message 'Test message' -Level Info 6>&1
20 $output | Should -Match '\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\] \[Info\] Test message'
21 }
22
23 It 'Outputs message with Warning level' {
24 $output = Write-SecurityLog -Message 'Warning message' -Level Warning 6>&1
25 $output | Should -Match '\[Warning\] Warning message'
26 }
27
28 It 'Outputs message with Error level' {
29 $output = Write-SecurityLog -Message 'Error message' -Level Error 6>&1
30 $output | Should -Match '\[Error\] Error message'
31 }
32
33 It 'Outputs message with Success level' {
34 $output = Write-SecurityLog -Message 'Success message' -Level Success 6>&1
35 $output | Should -Match '\[Success\] Success message'
36 }
37
38 It 'Outputs message with Debug level' {
39 $output = Write-SecurityLog -Message 'Debug message' -Level Debug 6>&1
40 $output | Should -Match '\[Debug\] Debug message'
41 }
42
43 It 'Outputs message with Verbose level' {
44 $output = Write-SecurityLog -Message 'Verbose message' -Level Verbose 6>&1
45 $output | Should -Match '\[Verbose\] Verbose message'
46 }
47
48 It 'Outputs blank line for empty message' {
49 # Mock Write-Host to capture the blank line call
50 $output = Write-SecurityLog -Message '' -Level Info 6>&1
51 # Empty message should not produce log entry output
52 $output | Should -BeNullOrEmpty
53 }
54
55 It 'Outputs blank line for whitespace-only message' {
56 $output = Write-SecurityLog -Message ' ' -Level Info 6>&1
57 $output | Should -BeNullOrEmpty
58 }
59
60 It 'Defaults to Info level' {
61 $output = Write-SecurityLog -Message 'Default level' 6>&1
62 $output | Should -Match '\[Info\] Default level'
63 }
64 }
65
66 Context 'Non-console output' {
67 It 'Does not output to console when OutputFormat is not console' {
68 $output = Write-SecurityLog -Message 'Test' -Level Info -OutputFormat 'silent' 6>&1
69 $output | Should -BeNullOrEmpty
70 }
71 }
72
73 Context 'File logging' {
74 BeforeEach {
75 $script:testLogPath = Join-Path $TestDrive 'test-security.log'
76 }
77
78 It 'Creates log directory if not exists' {
79 $nestedPath = Join-Path $TestDrive 'nested/dir/security.log'
80 Write-SecurityLog -Message 'Test' -Level Info -LogPath $nestedPath
81 Test-Path (Split-Path -Parent $nestedPath) | Should -BeTrue
82 }
83
84 It 'Appends log entry to file' {
85 Write-SecurityLog -Message 'First entry' -Level Info -LogPath $script:testLogPath
86 Write-SecurityLog -Message 'Second entry' -Level Warning -LogPath $script:testLogPath
87 $content = Get-Content -Path $script:testLogPath
88 $content.Count | Should -Be 2
89 $content[0] | Should -Match '\[Info\] First entry'
90 $content[1] | Should -Match '\[Warning\] Second entry'
91 }
92
93 It 'Handles file write errors gracefully' {
94 # Create a file and lock it
95 $lockedPath = Join-Path $TestDrive 'locked.log'
96 $file = [System.IO.File]::Open($lockedPath, 'Create', 'Write', 'None')
97 try {
98 # Should emit warning but not throw
99 { Write-SecurityLog -Message 'Test' -Level Info -LogPath $lockedPath 3>$null } | Should -Not -Throw
100 }
101 finally {
102 $file.Close()
103 }
104 }
105 }
106
107 Context 'CI annotation forwarding' {
108 BeforeAll {
109 Mock Write-CIAnnotation {} -ModuleName SecurityHelpers
110 Mock Write-Host {} -ModuleName SecurityHelpers
111 }
112
113 It 'Forwards Warning messages as CI annotations when -CIAnnotation is set' {
114 Write-SecurityLog -Message 'Test warning' -Level Warning -CIAnnotation
115 Should -Invoke Write-CIAnnotation -ModuleName SecurityHelpers -ParameterFilter { $Level -eq 'Warning' -and $Message -eq 'Test warning' } -Times 1 -Exactly
116 }
117
118 It 'Forwards Error messages as CI annotations when -CIAnnotation is set' {
119 Write-SecurityLog -Message 'Test error' -Level Error -CIAnnotation
120 Should -Invoke Write-CIAnnotation -ModuleName SecurityHelpers -ParameterFilter { $Level -eq 'Error' -and $Message -eq 'Test error' } -Times 1 -Exactly
121 }
122
123 It 'Does not forward Info messages as CI annotations' {
124 Write-SecurityLog -Message 'Test info' -Level Info -CIAnnotation
125 Should -Invoke Write-CIAnnotation -ModuleName SecurityHelpers -Times 0
126 }
127
128 It 'Does not forward any messages when -CIAnnotation is not set' {
129 Write-SecurityLog -Message 'No annotation' -Level Warning
130 Should -Invoke Write-CIAnnotation -ModuleName SecurityHelpers -Times 0
131 }
132 }
133}
134
135Describe 'New-SecurityIssue' -Tag 'Unit' {
136 It 'Returns PSCustomObject with all properties' {
137 $issue = New-SecurityIssue -Type 'TestType' -Severity 'High' -Title 'Test Title' -Description 'Test Description'
138 $issue.Type | Should -Be 'TestType'
139 $issue.Severity | Should -Be 'High'
140 $issue.Title | Should -Be 'Test Title'
141 $issue.Description | Should -Be 'Test Description'
142 }
143
144 It 'Sets Timestamp to current time' {
145 $issue = New-SecurityIssue -Type 'Test' -Severity 'Low' -Title 'Test' -Description 'Test'
146 $issue.Timestamp | Should -Match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z$'
147 }
148
149 It 'Validates Severity parameter - Low' {
150 $issue = New-SecurityIssue -Type 'Test' -Severity 'Low' -Title 'Test' -Description 'Test'
151 $issue.Severity | Should -Be 'Low'
152 }
153
154 It 'Validates Severity parameter - Medium' {
155 $issue = New-SecurityIssue -Type 'Test' -Severity 'Medium' -Title 'Test' -Description 'Test'
156 $issue.Severity | Should -Be 'Medium'
157 }
158
159 It 'Validates Severity parameter - High' {
160 $issue = New-SecurityIssue -Type 'Test' -Severity 'High' -Title 'Test' -Description 'Test'
161 $issue.Severity | Should -Be 'High'
162 }
163
164 It 'Validates Severity parameter - Critical' {
165 $issue = New-SecurityIssue -Type 'Test' -Severity 'Critical' -Title 'Test' -Description 'Test'
166 $issue.Severity | Should -Be 'Critical'
167 }
168
169 It 'Rejects invalid Severity value' {
170 { New-SecurityIssue -Type 'Test' -Severity 'Invalid' -Title 'Test' -Description 'Test' } | Should -Throw
171 }
172
173 It 'Includes optional File property' {
174 $issue = New-SecurityIssue -Type 'Test' -Severity 'Low' -Title 'Test' -Description 'Test' -File 'test.yml'
175 $issue.File | Should -Be 'test.yml'
176 }
177
178 It 'Includes optional Line property' {
179 $issue = New-SecurityIssue -Type 'Test' -Severity 'Low' -Title 'Test' -Description 'Test' -Line 42
180 $issue.Line | Should -Be 42
181 }
182
183 It 'Includes optional Recommendation property' {
184 $issue = New-SecurityIssue -Type 'Test' -Severity 'Low' -Title 'Test' -Description 'Test' -Recommendation 'Fix it'
185 $issue.Recommendation | Should -Be 'Fix it'
186 }
187
188 It 'Defaults Line to 0' {
189 $issue = New-SecurityIssue -Type 'Test' -Severity 'Low' -Title 'Test' -Description 'Test'
190 $issue.Line | Should -Be 0
191 }
192}
193
194Describe 'Write-SecurityReport' -Tag 'Unit' {
195 BeforeEach {
196 $script:testIssues = @(
197 [PSCustomObject]@{
198 Type = 'UnpinnedAction'
199 Severity = 'High'
200 Title = 'Unpinned action'
201 Description = 'actions/checkout@v4'
202 File = '.github/workflows/ci.yml'
203 Line = 10
204 Recommendation = 'Pin to SHA'
205 Timestamp = '2025-01-31 10:00:00'
206 },
207 [PSCustomObject]@{
208 Type = 'StaleSHA'
209 Severity = 'Medium'
210 Title = 'Stale SHA'
211 Description = 'SHA is 45 days old'
212 File = '.github/workflows/build.yml'
213 Line = 25
214 Recommendation = 'Update SHA'
215 Timestamp = '2025-01-31 10:00:00'
216 }
217 )
218 }
219
220 Context 'JSON output' {
221 It 'Returns valid JSON string' {
222 $output = Write-SecurityReport -Results $script:testIssues -OutputFormat json
223 { $output | ConvertFrom-Json } | Should -Not -Throw
224 }
225
226 It 'Includes Summary in JSON' {
227 $output = Write-SecurityReport -Results $script:testIssues -Summary 'Test summary' -OutputFormat json
228 $json = $output | ConvertFrom-Json
229 $json.Summary | Should -Be 'Test summary'
230 }
231
232 It 'Includes Issues array in JSON' {
233 $output = Write-SecurityReport -Results $script:testIssues -OutputFormat json
234 $json = $output | ConvertFrom-Json
235 $json.Issues.Count | Should -Be 2
236 }
237
238 It 'Includes Timestamp in JSON' {
239 $output = Write-SecurityReport -Results $script:testIssues -OutputFormat json
240 $output | Should -Match '"Timestamp":\s*"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z"'
241 }
242
243 It 'Includes Count in JSON' {
244 $output = Write-SecurityReport -Results $script:testIssues -OutputFormat json
245 $json = $output | ConvertFrom-Json
246 $json.Count | Should -Be 2
247 }
248
249 It 'Writes to file when OutputPath specified' {
250 $outputFile = Join-Path $TestDrive 'report.json'
251 Write-SecurityReport -Results $script:testIssues -OutputFormat json -OutputPath $outputFile
252 Test-Path $outputFile | Should -BeTrue
253 $content = Get-Content -Path $outputFile -Raw
254 { $content | ConvertFrom-Json } | Should -Not -Throw
255 }
256
257 It 'Creates output directory if not exists' {
258 $outputFile = Join-Path $TestDrive 'nested/dir/report.json'
259 Write-SecurityReport -Results $script:testIssues -OutputFormat json -OutputPath $outputFile
260 Test-Path $outputFile | Should -BeTrue
261 }
262
263 It 'Handles empty results array' {
264 $output = Write-SecurityReport -Results @() -OutputFormat json
265 $json = $output | ConvertFrom-Json
266 $json.Count | Should -Be 0
267 $json.Issues.Count | Should -Be 0
268 }
269 }
270
271 Context 'Console output' {
272 It 'Shows success message when no issues' {
273 # Capture console output (stream 6 is information)
274 $output = Write-SecurityReport -Results @() -OutputFormat console 6>&1
275 ($output -join ' ') | Should -Match 'No security issues found'
276 }
277
278 It 'Shows summary when provided with no issues' {
279 $output = Write-SecurityReport -Results @() -Summary 'Scan complete' -OutputFormat console 6>&1
280 ($output -join ' ') | Should -Match 'Scan complete'
281 }
282
283 It 'Shows warning header when issues found' {
284 $output = Write-SecurityReport -Results $script:testIssues -OutputFormat console 6>&1
285 ($output -join ' ') | Should -Match 'SECURITY ISSUES DETECTED'
286 }
287
288 It 'Outputs each issue severity and type' {
289 $output = Write-SecurityReport -Results $script:testIssues -OutputFormat console 6>&1
290 $text = $output -join ' '
291 $text | Should -Match '\[High\].*UnpinnedAction'
292 $text | Should -Match '\[Medium\].*StaleSHA'
293 }
294
295 It 'Shows total issue count' {
296 $output = Write-SecurityReport -Results $script:testIssues -OutputFormat console 6>&1
297 ($output -join ' ') | Should -Match 'Total issues: 2'
298 }
299 }
300
301 Context 'Markdown output' {
302 It 'Returns markdown table' {
303 $output = Write-SecurityReport -Results $script:testIssues -OutputFormat markdown
304 $output | Should -Match '\| Severity \| Type \| Title \| File \| Line \|'
305 $output | Should -Match '\|----------|------|-------|------|------|'
306 }
307
308 It 'Shows checkmark when no issues' {
309 $output = Write-SecurityReport -Results @() -OutputFormat markdown
310 $output | Should -Match ':white_check_mark:'
311 $output | Should -Match 'No security issues found'
312 }
313
314 It 'Includes summary when provided' {
315 $output = Write-SecurityReport -Results $script:testIssues -Summary 'Scan complete' -OutputFormat markdown
316 $output | Should -Match 'Scan complete'
317 }
318
319 It 'Includes total count header' {
320 $output = Write-SecurityReport -Results $script:testIssues -OutputFormat markdown
321 $output | Should -Match '\*\*Total issues: 2\*\*'
322 }
323
324 It 'Formats issue rows correctly' {
325 $output = Write-SecurityReport -Results $script:testIssues -OutputFormat markdown
326 $output | Should -Match '\| High \| UnpinnedAction \| Unpinned action \|'
327 $output | Should -Match '\| Medium \| StaleSHA \| Stale SHA \|'
328 }
329
330 It 'Writes to file when OutputPath specified' {
331 $outputFile = Join-Path $TestDrive 'report.md'
332 Write-SecurityReport -Results $script:testIssues -OutputFormat markdown -OutputPath $outputFile
333 Test-Path $outputFile | Should -BeTrue
334 $content = Get-Content -Path $outputFile -Raw
335 $content | Should -Match '## Security Scan Results'
336 }
337
338 It 'Uses dash for missing File' {
339 $issueNoFile = @([PSCustomObject]@{
340 Type = 'Test'
341 Severity = 'Low'
342 Title = 'Test'
343 Description = 'Test'
344 File = $null
345 Line = 0
346 Recommendation = 'Fix'
347 Timestamp = '2025-01-31 10:00:00'
348 })
349 $output = Write-SecurityReport -Results $issueNoFile -OutputFormat markdown
350 $output | Should -Match '\| - \| - \|$'
351 }
352 }
353}
354
355Describe 'Test-GitHubToken' -Tag 'Unit' {
356 Context 'Token validation' {
357 It 'Returns hashtable with expected keys' {
358 Mock Invoke-RestMethod -ModuleName SecurityHelpers { throw 'Simulated error' }
359 $result = Test-GitHubToken -Token 'test-token'
360 $result.Keys | Should -Contain 'Valid'
361 $result.Keys | Should -Contain 'Authenticated'
362 $result.Keys | Should -Contain 'RateLimit'
363 $result.Keys | Should -Contain 'Remaining'
364 $result.Keys | Should -Contain 'ResetAt'
365 $result.Keys | Should -Contain 'User'
366 $result.Keys | Should -Contain 'Message'
367 }
368
369 It 'Returns invalid for empty token' {
370 $result = Test-GitHubToken -Token ''
371 $result.Valid | Should -BeFalse
372 $result.Message | Should -Be 'Token is empty or null'
373 }
374
375 It 'Returns invalid for null-like token' {
376 $result = Test-GitHubToken -Token ([string]::Empty)
377 $result.Valid | Should -BeFalse
378 }
379
380 It 'Sets appropriate message for 401 response' {
381 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
382 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Unauthorized)
383 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('Unauthorized', $response)
384 throw $exception
385 }
386 $result = Test-GitHubToken -Token 'invalid-token'
387 $result.Valid | Should -BeFalse
388 $result.Message | Should -Be 'Token is invalid or expired'
389 }
390
391 It 'Sets appropriate message for 403 response' {
392 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
393 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Forbidden)
394 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('Forbidden', $response)
395 throw $exception
396 }
397 $result = Test-GitHubToken -Token 'forbidden-token'
398 $result.Valid | Should -BeFalse
399 $result.Message | Should -Be 'Token lacks required permissions or rate limit exceeded'
400 }
401
402 It 'Handles successful token validation' {
403 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
404 @{
405 data = @{
406 viewer = @{ login = 'test-user' }
407 rateLimit = @{
408 limit = 5000
409 remaining = 4999
410 resetAt = '2025-12-31T00:00:00Z'
411 }
412 }
413 }
414 }
415 $result = Test-GitHubToken -Token 'valid-token'
416 $result.Valid | Should -BeTrue
417 $result.Authenticated | Should -BeTrue
418 $result.User | Should -Be 'test-user'
419 $result.RateLimit | Should -Be 5000
420 $result.Remaining | Should -Be 4999
421 $result.Message | Should -Be 'Authenticated as test-user'
422 }
423
424 It 'Sets ResetAt from GraphQL response' {
425 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
426 @{
427 data = @{
428 viewer = @{ login = 'test-user' }
429 rateLimit = @{
430 limit = 5000
431 remaining = 4999
432 resetAt = '2025-12-31T00:00:00Z'
433 }
434 }
435 }
436 }
437 $result = Test-GitHubToken -Token 'valid-token'
438 $result.ResetAt | Should -Be '2025-12-31T00:00:00Z'
439 }
440
441 It 'Returns unauthenticated when viewer is null but rateLimit exists' {
442 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
443 @{
444 data = @{
445 viewer = $null
446 rateLimit = @{
447 limit = 60
448 remaining = 100
449 resetAt = '2025-12-31T00:00:00Z'
450 }
451 }
452 }
453 }
454 $result = Test-GitHubToken -Token 'unauthenticated-token'
455 $result.Valid | Should -BeTrue
456 $result.Authenticated | Should -BeFalse
457 $result.User | Should -BeNullOrEmpty
458 $result.RateLimit | Should -Be 60
459 $result.Remaining | Should -Be 100
460 $result.Message | Should -Be 'Unauthenticated access - limited rate limits'
461 }
462
463 It 'Appends low rate-limit warning when remaining is below threshold' {
464 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
465 @{
466 data = @{
467 viewer = @{ login = 'test-user' }
468 rateLimit = @{
469 limit = 5000
470 remaining = 50
471 resetAt = '2025-12-31T00:00:00Z'
472 }
473 }
474 }
475 }
476 $result = Test-GitHubToken -Token 'low-rate-token'
477 $result.Valid | Should -BeTrue
478 $result.Message | Should -BeLike '*WARNING: Only 50 API calls remaining*'
479 }
480
481 It 'Returns invalid for malformed GraphQL response without data' {
482 Mock Invoke-RestMethod -ModuleName SecurityHelpers { @{} }
483 $result = Test-GitHubToken -Token 'malformed-token'
484 $result.Valid | Should -BeFalse
485 $result.Authenticated | Should -BeFalse
486 $result.Message | Should -BeNullOrEmpty
487 }
488 }
489}
490
491Describe 'Invoke-GitHubAPIWithRetry' -Tag 'Unit' {
492 Context 'Successful requests' {
493 It 'Returns response on successful GET' {
494 Mock Invoke-RestMethod -ModuleName SecurityHelpers { @{ data = 'test' } }
495 $headers = @{ Authorization = 'Bearer test' }
496 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers
497 $result.data | Should -Be 'test'
498 }
499
500 It 'Passes Body for POST requests' {
501 Mock Invoke-RestMethod -ModuleName SecurityHelpers -ParameterFilter { $Body -eq '{"test":"data"}' } { @{ success = $true } }
502 $headers = @{ Authorization = 'Bearer test' }
503 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method POST -Headers $headers -Body '{"test":"data"}'
504 $result.success | Should -BeTrue
505 }
506
507 It 'Uses GET method by default' {
508 Mock Invoke-RestMethod -ModuleName SecurityHelpers -ParameterFilter { $Method -eq 'GET' } { @{ method = 'GET' } }
509 $headers = @{ Authorization = 'Bearer test' }
510 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers
511 $result.method | Should -Be 'GET'
512 }
513 }
514
515 Context 'Retry behavior' {
516 It 'Retries on 429 rate limit' {
517 $script:callCount = 0
518 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
519 $script:callCount++
520 if ($script:callCount -lt 2) {
521 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::TooManyRequests)
522 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('Rate limited', $response)
523 throw $exception
524 }
525 return @{ success = $true }
526 }
527 $headers = @{ Authorization = 'Bearer test' }
528 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1
529 $result.success | Should -BeTrue
530 $script:callCount | Should -Be 2
531 }
532
533 It 'Retries on 5xx server errors' {
534 $script:callCount = 0
535 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
536 $script:callCount++
537 if ($script:callCount -lt 2) {
538 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::InternalServerError)
539 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('Server error', $response)
540 throw $exception
541 }
542 return @{ success = $true }
543 }
544 $headers = @{ Authorization = 'Bearer test' }
545 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1
546 $result.success | Should -BeTrue
547 }
548
549 It 'Does not retry on 404 errors' {
550 $script:callCount = 0
551 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
552 $script:callCount++
553 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::NotFound)
554 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('Not found', $response)
555 throw $exception
556 }
557 $headers = @{ Authorization = 'Bearer test' }
558 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 2>$null
559 $result | Should -BeNullOrEmpty
560 $script:callCount | Should -Be 1
561 }
562
563 It 'Returns null after max retries exceeded' {
564 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
565 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::TooManyRequests)
566 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('Rate limited', $response)
567 throw $exception
568 }
569 $headers = @{ Authorization = 'Bearer test' }
570 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -MaxRetries 2 -InitialDelaySeconds 1 2>$null 3>$null
571 $result | Should -BeNullOrEmpty
572 }
573
574 It 'Uses exponential backoff' {
575 $script:delays = @()
576 $script:callCount = 0
577 Mock Start-Sleep -ModuleName SecurityHelpers { $script:delays += $Seconds }
578 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
579 $script:callCount++
580 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::TooManyRequests)
581 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('Rate limited', $response)
582 throw $exception
583 }
584 $headers = @{ Authorization = 'Bearer test' }
585 Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -MaxRetries 3 -InitialDelaySeconds 2 2>$null 3>$null
586 # Should have delays of 2, 4 (exponential backoff)
587 $script:delays.Count | Should -Be 2
588 $script:delays[0] | Should -Be 2
589 $script:delays[1] | Should -Be 4
590 }
591 }
592
593 Context 'Parameter validation' {
594 It 'Validates Method parameter' {
595 $headers = @{ Authorization = 'Bearer test' }
596 { Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'INVALID' -Headers $headers } | Should -Throw
597 }
598
599 It 'Validates MaxRetries range' {
600 $headers = @{ Authorization = 'Bearer test' }
601 { Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -MaxRetries 0 } | Should -Throw
602 { Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -MaxRetries 11 } | Should -Throw
603 }
604
605 It 'Validates InitialDelaySeconds range' {
606 $headers = @{ Authorization = 'Bearer test' }
607 { Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 0 } | Should -Throw
608 { Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 61 } | Should -Throw
609 }
610 }
611
612 Context 'Message-based status code extraction (cross-platform fallback)' {
613 # These tests verify that status codes can be extracted from exception message text
614 # when Response.StatusCode is unavailable (common on Linux)
615
616 It 'Maps "Not found" message to 404 and does not retry' {
617 $script:callCount = 0
618 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
619 $script:callCount++
620 throw [System.InvalidOperationException]::new('Not found')
621 }
622 $headers = @{ Authorization = 'Bearer test' }
623 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 2>$null
624 $result | Should -BeNullOrEmpty
625 $script:callCount | Should -Be 1 # 404 is not retryable
626 }
627
628 It 'Maps "Unauthorized" message to 401 and does not retry' {
629 $script:callCount = 0
630 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
631 $script:callCount++
632 throw [System.InvalidOperationException]::new('Unauthorized')
633 }
634 $headers = @{ Authorization = 'Bearer test' }
635 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 2>$null
636 $result | Should -BeNullOrEmpty
637 $script:callCount | Should -Be 1 # 401 is not retryable
638 }
639
640 It 'Maps "Forbidden" message to 403 and retries' {
641 $script:callCount = 0
642 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
643 $script:callCount++
644 if ($script:callCount -lt 2) {
645 throw [System.InvalidOperationException]::new('Forbidden')
646 }
647 return @{ success = $true }
648 }
649 $headers = @{ Authorization = 'Bearer test' }
650 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
651 $result.success | Should -BeTrue
652 $script:callCount | Should -Be 2 # 403 is retryable (rate limit)
653 }
654
655 It 'Maps "Rate limited" message to 429 and retries' {
656 $script:callCount = 0
657 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
658 $script:callCount++
659 if ($script:callCount -lt 2) {
660 throw [System.InvalidOperationException]::new('Rate limited')
661 }
662 return @{ success = $true }
663 }
664 $headers = @{ Authorization = 'Bearer test' }
665 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
666 $result.success | Should -BeTrue
667 $script:callCount | Should -Be 2 # 429 is retryable
668 }
669
670 It 'Maps "Too many requests" message to 429 and retries' {
671 $script:callCount = 0
672 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
673 $script:callCount++
674 if ($script:callCount -lt 2) {
675 throw [System.InvalidOperationException]::new('Too many requests')
676 }
677 return @{ success = $true }
678 }
679 $headers = @{ Authorization = 'Bearer test' }
680 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
681 $result.success | Should -BeTrue
682 $script:callCount | Should -Be 2
683 }
684
685 It 'Maps "Server error" message to 500 and retries' {
686 $script:callCount = 0
687 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
688 $script:callCount++
689 if ($script:callCount -lt 2) {
690 throw [System.InvalidOperationException]::new('Server error')
691 }
692 return @{ success = $true }
693 }
694 $headers = @{ Authorization = 'Bearer test' }
695 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
696 $result.success | Should -BeTrue
697 $script:callCount | Should -Be 2
698 }
699
700 It 'Maps "Internal server error" message to 500 and retries' {
701 $script:callCount = 0
702 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
703 $script:callCount++
704 if ($script:callCount -lt 2) {
705 throw [System.InvalidOperationException]::new('Internal server error occurred')
706 }
707 return @{ success = $true }
708 }
709 $headers = @{ Authorization = 'Bearer test' }
710 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
711 $result.success | Should -BeTrue
712 $script:callCount | Should -Be 2
713 }
714
715 It 'Maps "Bad gateway" message to 502 and retries' {
716 $script:callCount = 0
717 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
718 $script:callCount++
719 if ($script:callCount -lt 2) {
720 throw [System.InvalidOperationException]::new('Bad gateway')
721 }
722 return @{ success = $true }
723 }
724 $headers = @{ Authorization = 'Bearer test' }
725 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
726 $result.success | Should -BeTrue
727 $script:callCount | Should -Be 2
728 }
729
730 It 'Maps "Service unavailable" message to 503 and retries' {
731 $script:callCount = 0
732 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
733 $script:callCount++
734 if ($script:callCount -lt 2) {
735 throw [System.InvalidOperationException]::new('Service unavailable')
736 }
737 return @{ success = $true }
738 }
739 $headers = @{ Authorization = 'Bearer test' }
740 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
741 $result.success | Should -BeTrue
742 $script:callCount | Should -Be 2
743 }
744
745 It 'Maps "Gateway timeout" message to 504 and retries' {
746 $script:callCount = 0
747 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
748 $script:callCount++
749 if ($script:callCount -lt 2) {
750 throw [System.InvalidOperationException]::new('Gateway timeout')
751 }
752 return @{ success = $true }
753 }
754 $headers = @{ Authorization = 'Bearer test' }
755 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
756 $result.success | Should -BeTrue
757 $script:callCount | Should -Be 2
758 }
759
760 It 'Handles case-insensitive message matching' {
761 $script:callCount = 0
762 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
763 $script:callCount++
764 if ($script:callCount -lt 2) {
765 throw [System.InvalidOperationException]::new('RATE LIMITED')
766 }
767 return @{ success = $true }
768 }
769 $headers = @{ Authorization = 'Bearer test' }
770 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
771 $result.success | Should -BeTrue
772 $script:callCount | Should -Be 2
773 }
774
775 It 'Does not retry unknown errors when no status code is extractable' {
776 $script:callCount = 0
777 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
778 $script:callCount++
779 throw [System.InvalidOperationException]::new('Some unknown network error')
780 }
781 $headers = @{ Authorization = 'Bearer test' }
782 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 2>$null
783 $result | Should -BeNullOrEmpty
784 $script:callCount | Should -Be 1 # Unknown errors are not retried
785 }
786 }
787}
788