microsoft/hve-core

Public

mirrored fromhttps://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/SecurityHelpers.Tests.ps1

707lines · modecode

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