microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/add-second-skill-package

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/tests/security/SecurityHelpers.Tests.ps1

792lines · 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 'Valid'
366 $result.Keys | Should -Contain 'Authenticated'
367 $result.Keys | Should -Contain 'RateLimit'
368 $result.Keys | Should -Contain 'Remaining'
369 $result.Keys | Should -Contain 'ResetAt'
370 $result.Keys | Should -Contain 'User'
371 $result.Keys | Should -Contain 'Message'
372 }
373
374 It 'Returns invalid for empty token' {
375 $result = Test-GitHubToken -Token ''
376 $result.Valid | Should -BeFalse
377 $result.Message | Should -Be 'Token is empty or null'
378 }
379
380 It 'Returns invalid for null-like token' {
381 $result = Test-GitHubToken -Token ([string]::Empty)
382 $result.Valid | Should -BeFalse
383 }
384
385 It 'Sets appropriate message for 401 response' {
386 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
387 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Unauthorized)
388 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('Unauthorized', $response)
389 throw $exception
390 }
391 $result = Test-GitHubToken -Token 'invalid-token'
392 $result.Valid | Should -BeFalse
393 $result.Message | Should -Be 'Token is invalid or expired'
394 }
395
396 It 'Sets appropriate message for 403 response' {
397 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
398 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::Forbidden)
399 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('Forbidden', $response)
400 throw $exception
401 }
402 $result = Test-GitHubToken -Token 'forbidden-token'
403 $result.Valid | Should -BeFalse
404 $result.Message | Should -Be 'Token lacks required permissions or rate limit exceeded'
405 }
406
407 It 'Handles successful token validation' {
408 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
409 @{
410 data = @{
411 viewer = @{ login = 'test-user' }
412 rateLimit = @{
413 limit = 5000
414 remaining = 4999
415 resetAt = '2025-12-31T00:00:00Z'
416 }
417 }
418 }
419 }
420 $result = Test-GitHubToken -Token 'valid-token'
421 $result.Valid | Should -BeTrue
422 $result.Authenticated | Should -BeTrue
423 $result.User | Should -Be 'test-user'
424 $result.RateLimit | Should -Be 5000
425 $result.Remaining | Should -Be 4999
426 $result.Message | Should -Be 'Authenticated as test-user'
427 }
428
429 It 'Sets ResetAt from GraphQL response' {
430 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
431 @{
432 data = @{
433 viewer = @{ login = 'test-user' }
434 rateLimit = @{
435 limit = 5000
436 remaining = 4999
437 resetAt = '2025-12-31T00:00:00Z'
438 }
439 }
440 }
441 }
442 $result = Test-GitHubToken -Token 'valid-token'
443 $result.ResetAt | Should -Be '2025-12-31T00:00:00Z'
444 }
445
446 It 'Returns unauthenticated when viewer is null but rateLimit exists' {
447 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
448 @{
449 data = @{
450 viewer = $null
451 rateLimit = @{
452 limit = 60
453 remaining = 100
454 resetAt = '2025-12-31T00:00:00Z'
455 }
456 }
457 }
458 }
459 $result = Test-GitHubToken -Token 'unauthenticated-token'
460 $result.Valid | Should -BeTrue
461 $result.Authenticated | Should -BeFalse
462 $result.User | Should -BeNullOrEmpty
463 $result.RateLimit | Should -Be 60
464 $result.Remaining | Should -Be 100
465 $result.Message | Should -Be 'Unauthenticated access - limited rate limits'
466 }
467
468 It 'Appends low rate-limit warning when remaining is below threshold' {
469 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
470 @{
471 data = @{
472 viewer = @{ login = 'test-user' }
473 rateLimit = @{
474 limit = 5000
475 remaining = 50
476 resetAt = '2025-12-31T00:00:00Z'
477 }
478 }
479 }
480 }
481 $result = Test-GitHubToken -Token 'low-rate-token'
482 $result.Valid | Should -BeTrue
483 $result.Message | Should -BeLike '*WARNING: Only 50 API calls remaining*'
484 }
485
486 It 'Returns invalid for malformed GraphQL response without data' {
487 Mock Invoke-RestMethod -ModuleName SecurityHelpers { @{} }
488 $result = Test-GitHubToken -Token 'malformed-token'
489 $result.Valid | Should -BeFalse
490 $result.Authenticated | Should -BeFalse
491 $result.Message | Should -BeNullOrEmpty
492 }
493 }
494}
495
496Describe 'Invoke-GitHubAPIWithRetry' -Tag 'Unit' {
497 Context 'Successful requests' {
498 It 'Returns response on successful GET' {
499 Mock Invoke-RestMethod -ModuleName SecurityHelpers { @{ data = 'test' } }
500 $headers = @{ Authorization = 'Bearer test' }
501 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers
502 $result.data | Should -Be 'test'
503 }
504
505 It 'Passes Body for POST requests' {
506 Mock Invoke-RestMethod -ModuleName SecurityHelpers -ParameterFilter { $Body -eq '{"test":"data"}' } { @{ success = $true } }
507 $headers = @{ Authorization = 'Bearer test' }
508 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method POST -Headers $headers -Body '{"test":"data"}'
509 $result.success | Should -BeTrue
510 }
511
512 It 'Uses GET method by default' {
513 Mock Invoke-RestMethod -ModuleName SecurityHelpers -ParameterFilter { $Method -eq 'GET' } { @{ method = 'GET' } }
514 $headers = @{ Authorization = 'Bearer test' }
515 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers
516 $result.method | Should -Be 'GET'
517 }
518 }
519
520 Context 'Retry behavior' {
521 It 'Retries on 429 rate limit' {
522 $script:callCount = 0
523 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
524 $script:callCount++
525 if ($script:callCount -lt 2) {
526 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::TooManyRequests)
527 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('Rate limited', $response)
528 throw $exception
529 }
530 return @{ success = $true }
531 }
532 $headers = @{ Authorization = 'Bearer test' }
533 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1
534 $result.success | Should -BeTrue
535 $script:callCount | Should -Be 2
536 }
537
538 It 'Retries on 5xx server errors' {
539 $script:callCount = 0
540 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
541 $script:callCount++
542 if ($script:callCount -lt 2) {
543 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::InternalServerError)
544 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('Server error', $response)
545 throw $exception
546 }
547 return @{ success = $true }
548 }
549 $headers = @{ Authorization = 'Bearer test' }
550 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1
551 $result.success | Should -BeTrue
552 }
553
554 It 'Does not retry on 404 errors' {
555 $script:callCount = 0
556 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
557 $script:callCount++
558 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::NotFound)
559 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('Not found', $response)
560 throw $exception
561 }
562 $headers = @{ Authorization = 'Bearer test' }
563 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 2>$null
564 $result | Should -BeNullOrEmpty
565 $script:callCount | Should -Be 1
566 }
567
568 It 'Returns null after max retries exceeded' {
569 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
570 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::TooManyRequests)
571 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('Rate limited', $response)
572 throw $exception
573 }
574 $headers = @{ Authorization = 'Bearer test' }
575 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -MaxRetries 2 -InitialDelaySeconds 1 2>$null 3>$null
576 $result | Should -BeNullOrEmpty
577 }
578
579 It 'Uses exponential backoff' {
580 $script:delays = @()
581 $script:callCount = 0
582 Mock Start-Sleep -ModuleName SecurityHelpers { $script:delays += $Seconds }
583 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
584 $script:callCount++
585 $response = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::TooManyRequests)
586 $exception = [Microsoft.PowerShell.Commands.HttpResponseException]::new('Rate limited', $response)
587 throw $exception
588 }
589 $headers = @{ Authorization = 'Bearer test' }
590 Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -MaxRetries 3 -InitialDelaySeconds 2 2>$null 3>$null
591 # Should have delays of 2, 4 (exponential backoff)
592 $script:delays.Count | Should -Be 2
593 $script:delays[0] | Should -Be 2
594 $script:delays[1] | Should -Be 4
595 }
596 }
597
598 Context 'Parameter validation' {
599 It 'Validates Method parameter' {
600 $headers = @{ Authorization = 'Bearer test' }
601 { Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Method 'INVALID' -Headers $headers } | Should -Throw
602 }
603
604 It 'Validates MaxRetries range' {
605 $headers = @{ Authorization = 'Bearer test' }
606 { Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -MaxRetries 0 } | Should -Throw
607 { Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -MaxRetries 11 } | Should -Throw
608 }
609
610 It 'Validates InitialDelaySeconds range' {
611 $headers = @{ Authorization = 'Bearer test' }
612 { Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 0 } | Should -Throw
613 { Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 61 } | Should -Throw
614 }
615 }
616
617 Context 'Message-based status code extraction (cross-platform fallback)' {
618 # These tests verify that status codes can be extracted from exception message text
619 # when Response.StatusCode is unavailable (common on Linux)
620
621 It 'Maps "Not found" message to 404 and does not retry' {
622 $script:callCount = 0
623 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
624 $script:callCount++
625 throw [System.InvalidOperationException]::new('Not found')
626 }
627 $headers = @{ Authorization = 'Bearer test' }
628 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 2>$null
629 $result | Should -BeNullOrEmpty
630 $script:callCount | Should -Be 1 # 404 is not retryable
631 }
632
633 It 'Maps "Unauthorized" message to 401 and does not retry' {
634 $script:callCount = 0
635 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
636 $script:callCount++
637 throw [System.InvalidOperationException]::new('Unauthorized')
638 }
639 $headers = @{ Authorization = 'Bearer test' }
640 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 2>$null
641 $result | Should -BeNullOrEmpty
642 $script:callCount | Should -Be 1 # 401 is not retryable
643 }
644
645 It 'Maps "Forbidden" message to 403 and retries' {
646 $script:callCount = 0
647 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
648 $script:callCount++
649 if ($script:callCount -lt 2) {
650 throw [System.InvalidOperationException]::new('Forbidden')
651 }
652 return @{ success = $true }
653 }
654 $headers = @{ Authorization = 'Bearer test' }
655 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
656 $result.success | Should -BeTrue
657 $script:callCount | Should -Be 2 # 403 is retryable (rate limit)
658 }
659
660 It 'Maps "Rate limited" message to 429 and retries' {
661 $script:callCount = 0
662 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
663 $script:callCount++
664 if ($script:callCount -lt 2) {
665 throw [System.InvalidOperationException]::new('Rate limited')
666 }
667 return @{ success = $true }
668 }
669 $headers = @{ Authorization = 'Bearer test' }
670 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
671 $result.success | Should -BeTrue
672 $script:callCount | Should -Be 2 # 429 is retryable
673 }
674
675 It 'Maps "Too many requests" message to 429 and retries' {
676 $script:callCount = 0
677 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
678 $script:callCount++
679 if ($script:callCount -lt 2) {
680 throw [System.InvalidOperationException]::new('Too many requests')
681 }
682 return @{ success = $true }
683 }
684 $headers = @{ Authorization = 'Bearer test' }
685 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
686 $result.success | Should -BeTrue
687 $script:callCount | Should -Be 2
688 }
689
690 It 'Maps "Server error" message to 500 and retries' {
691 $script:callCount = 0
692 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
693 $script:callCount++
694 if ($script:callCount -lt 2) {
695 throw [System.InvalidOperationException]::new('Server error')
696 }
697 return @{ success = $true }
698 }
699 $headers = @{ Authorization = 'Bearer test' }
700 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
701 $result.success | Should -BeTrue
702 $script:callCount | Should -Be 2
703 }
704
705 It 'Maps "Internal server error" message to 500 and retries' {
706 $script:callCount = 0
707 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
708 $script:callCount++
709 if ($script:callCount -lt 2) {
710 throw [System.InvalidOperationException]::new('Internal server error occurred')
711 }
712 return @{ success = $true }
713 }
714 $headers = @{ Authorization = 'Bearer test' }
715 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
716 $result.success | Should -BeTrue
717 $script:callCount | Should -Be 2
718 }
719
720 It 'Maps "Bad gateway" message to 502 and retries' {
721 $script:callCount = 0
722 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
723 $script:callCount++
724 if ($script:callCount -lt 2) {
725 throw [System.InvalidOperationException]::new('Bad gateway')
726 }
727 return @{ success = $true }
728 }
729 $headers = @{ Authorization = 'Bearer test' }
730 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
731 $result.success | Should -BeTrue
732 $script:callCount | Should -Be 2
733 }
734
735 It 'Maps "Service unavailable" message to 503 and retries' {
736 $script:callCount = 0
737 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
738 $script:callCount++
739 if ($script:callCount -lt 2) {
740 throw [System.InvalidOperationException]::new('Service unavailable')
741 }
742 return @{ success = $true }
743 }
744 $headers = @{ Authorization = 'Bearer test' }
745 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
746 $result.success | Should -BeTrue
747 $script:callCount | Should -Be 2
748 }
749
750 It 'Maps "Gateway timeout" message to 504 and retries' {
751 $script:callCount = 0
752 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
753 $script:callCount++
754 if ($script:callCount -lt 2) {
755 throw [System.InvalidOperationException]::new('Gateway timeout')
756 }
757 return @{ success = $true }
758 }
759 $headers = @{ Authorization = 'Bearer test' }
760 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
761 $result.success | Should -BeTrue
762 $script:callCount | Should -Be 2
763 }
764
765 It 'Handles case-insensitive message matching' {
766 $script:callCount = 0
767 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
768 $script:callCount++
769 if ($script:callCount -lt 2) {
770 throw [System.InvalidOperationException]::new('RATE LIMITED')
771 }
772 return @{ success = $true }
773 }
774 $headers = @{ Authorization = 'Bearer test' }
775 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 3>$null
776 $result.success | Should -BeTrue
777 $script:callCount | Should -Be 2
778 }
779
780 It 'Does not retry unknown errors when no status code is extractable' {
781 $script:callCount = 0
782 Mock Invoke-RestMethod -ModuleName SecurityHelpers {
783 $script:callCount++
784 throw [System.InvalidOperationException]::new('Some unknown network error')
785 }
786 $headers = @{ Authorization = 'Bearer test' }
787 $result = Invoke-GitHubAPIWithRetry -Uri 'https://api.github.com/test' -Headers $headers -InitialDelaySeconds 1 2>$null
788 $result | Should -BeNullOrEmpty
789 $script:callCount | Should -Be 1 # Unknown errors are not retried
790 }
791 }
792}
793