microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
docs/621-ai-artifacts

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/tests/security/SecurityHelpers.Tests.ps1

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