microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
fix/dependabot-uuid-postcss-overrides

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/tests/security/SecurityHelpers.Tests.ps1

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