microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
8b197250063fc1629244f661f78baf9022cebbb0

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/tests/security/SecurityHelpers.Tests.ps1

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