functions/github/Invoke-GitHubRestMethod.Tests.ps1
|
$here = Split-Path -Parent $MyInvocation.MyCommand.Path $sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.Tests\.', '.' . "$here\$sut" Describe "Invoke-GitHubRestMethod" { $mockSplat = @{ uri = "https://api.github.com/repos/does/notexist" verb = "GET" body = @{} token = "MOCK_TOKEN" } Context "When invoking with valid parameters" { Mock Invoke-RestMethod { return @{ StatusCode = 200 } } It "Should return a successful response" { $result = Invoke-GitHubRestMethod @mockSplat Assert-MockCalled Invoke-RestMethod -Exactly 1 $result | Should -Not -BeNullOrEmpty $result.StatusCode | Should -Be 200 } } Context "When requesting all pages of a paginated response" { Mock Invoke-RestMethod { # Simulate '-ResponseHeadersVariable' by setting the variable in the caller's scope $link = switch -Wildcard ($Uri) { "*page=2" { '<https://api.github.com/organizations/1/repos?page=3>; rel="next", <https://api.github.com/organizations/1/repos?page=3>; rel="last"' } "*page=3" { '<https://api.github.com/organizations/1/repos?page=1>; rel="first", <https://api.github.com/organizations/1/repos?page=2>; rel="prev"' } default { '<https://api.github.com/organizations/1/repos?page=2>; rel="next", <https://api.github.com/organizations/1/repos?page=3>; rel="last"' } } Set-Variable -Name $ResponseHeadersVariable -Value @{ Link = @($link) } -Scope 3 $page = $Uri -match 'page=(\d+)' ? $Matches[1] : 1 return @(@{ name = "repo$page-a" }, @{ name = "repo$page-b" }) } It "Should return the results from all pages" { $result = Invoke-GitHubRestMethod @mockSplat -AllPages Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 3 $result.Count | Should -Be 6 $result[-1].name | Should -Be "repo3-b" } It "Should send the Authorization header when requesting every page" { Invoke-GitHubRestMethod @mockSplat -AllPages | Out-Null Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 3 -ParameterFilter { $Headers.Authorization -eq "Token MOCK_TOKEN" } Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 1 -ParameterFilter { $Uri -like "*page=2" } Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 1 -ParameterFilter { $Uri -like "*page=3" } } It "Should not rely on Invoke-RestMethod to follow the pagination links" { Invoke-GitHubRestMethod @mockSplat -AllPages | Out-Null Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 0 -ParameterFilter { $FollowRelLink } } It "Should only return the first page when not requesting all pages" { $result = Invoke-GitHubRestMethod @mockSplat Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 1 $result.Count | Should -Be 2 } } Context "When a pagination link points to a different origin" { Mock Invoke-RestMethod { if ($Uri -notlike "*page=2") { Set-Variable -Name $ResponseHeadersVariable -Value @{ Link = @("<$($script:nextPageLink)>; rel=`"next`"") } -Scope 3 } return @(@{ name = "repo" }) } $crossOriginTestCases = @( @{ change = "host"; link = "https://evil.example.com/organizations/1/repos?page=2" } # keep the port the same, so that only the scheme differs @{ change = "scheme"; link = "http://api.github.com:443/organizations/1/repos?page=2" } @{ change = "port"; link = "https://api.github.com:8443/organizations/1/repos?page=2" } ) It "Should throw an exception when the <change> differs" -TestCases $crossOriginTestCases { param ($change, $link) $script:nextPageLink = $link { Invoke-GitHubRestMethod @mockSplat -AllPages } | Should -Throw "different origin" } It "Should not send a request to the other origin when the <change> differs" -TestCases $crossOriginTestCases { param ($change, $link) $script:nextPageLink = $link { Invoke-GitHubRestMethod @mockSplat -AllPages } | Should -Throw Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 1 Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 0 -ParameterFilter { $Uri -eq $link } } It "Should follow a link that explicitly specifies the default port" { $script:nextPageLink = "https://api.github.com:443/organizations/1/repos?page=2" $result = Invoke-GitHubRestMethod @mockSplat -AllPages Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 1 -ParameterFilter { $Uri -eq "https://api.github.com:443/organizations/1/repos?page=2" } $result.Count | Should -Be 2 } } Context "When a later page remains rate limited after all retries" { Mock Invoke-RestMethod { if ($Uri -like "*page=2") { $statusCode = 429 $response = New-Object System.Net.Http.HttpResponseMessage $statusCode $response.Headers.Add('Retry-After', '0') $exception = New-Object Microsoft.PowerShell.Commands.HttpResponseException "$statusCode ($($response.ReasonPhrase))", $response $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidOperation $errorID = 'WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand' throw (New-Object Management.Automation.ErrorRecord $exception, $errorID, $errorCategory, $null) } Set-Variable -Name $ResponseHeadersVariable -Value @{ Link = @('<https://api.github.com/organizations/1/repos?page=2>; rel="next"') } -Scope 3 return @(@{ name = "repo1-a" }, @{ name = "repo1-b" }) } It "Should throw an exception" { { Invoke-GitHubRestMethod @mockSplat -AllPages -MaxRetries 2 } | Should -Throw "Rate limit still exceeded" Assert-MockCalled Invoke-RestMethod -Scope It -Exactly 3 -ParameterFilter { $Uri -like "*page=2" } } It "Should not return the results from the earlier pages" { $script:received = @() try { Invoke-GitHubRestMethod @mockSplat -AllPages -MaxRetries 2 | ForEach-Object { $script:received += $_ } } catch {} $script:received.Count | Should -Be 0 } } Context "When encountering an error status code" { Mock Invoke-RestMethod { $errorDetails = '{"code": 1, "message": "BadRequest", "more_info": "", "status": 400}' $statusCode = 400 $response = New-Object System.Net.Http.HttpResponseMessage $statusCode $exception = New-Object Microsoft.PowerShell.Commands.HttpResponseException "$statusCode ($($response.ReasonPhrase))", $response $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidOperation $errorID = 'WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand' $targetObject = $null $errorRecord = New-Object Management.Automation.ErrorRecord $exception, $errorID, $errorCategory, $targetObject $errorRecord.ErrorDetails = $errorDetails throw $errorRecord } It "Should throw an exception" { { Invoke-GitHubRestMethod @mockSplat } | Should -Throw } } Context "When encountering an ignored error status code" { Mock Invoke-RestMethod { $errorDetails = '{"code": 1, "message": "BadRequest", "more_info": "", "status": 400}' $statusCode = 400 $response = New-Object System.Net.Http.HttpResponseMessage $statusCode $exception = New-Object Microsoft.PowerShell.Commands.HttpResponseException "$statusCode ($($response.ReasonPhrase))", $response $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidOperation $errorID = 'WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand' $targetObject = $null $errorRecord = New-Object Management.Automation.ErrorRecord $exception, $errorID, $errorCategory, $targetObject $errorRecord.ErrorDetails = $errorDetails throw $errorRecord } It "Should not throw an exception" { { Invoke-GitHubRestMethod @mockSplat -HttpErrorStatusCodesToIgnore @(400) } | Should -Not -Throw } It "Should return null" { $result = Invoke-GitHubRestMethod @mockSplat -HttpErrorStatusCodesToIgnore @(400) $result | Should -BeNullOrEmpty } } Context "When rate limit is exceeded and given a 'Retry-After' response header" { $script:pesterHasRetried = $false Mock Invoke-RestMethod { try { if (!$pesterHasRetried) { $errorDetails = '{"code": 1, "message": "BadRequest", "more_info": "", "status": 429}' $statusCode = 429 $response = New-Object System.Net.Http.HttpResponseMessage $statusCode $response.Headers.Add('Retry-After', '1') $exception = New-Object Microsoft.PowerShell.Commands.HttpResponseException "$statusCode ($($response.ReasonPhrase))", $response $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidOperation $errorID = 'WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand' $targetObject = $null $errorRecord = New-Object Management.Automation.ErrorRecord $exception, $errorID, $errorCategory, $targetObject $errorRecord.ErrorDetails = $errorDetails throw $errorRecord } else { return @{ StatusCode = 200 } } } finally { $script:pesterHasRetried = $true } } It "Should wait for the period specified in the Retry-After header" { # Act $result = Invoke-GitHubRestMethod @mockSplat # Assert $result | Should -Not -BeNullOrEmpty $result.StatusCode | Should -Be 200 Assert-MockCalled Invoke-RestMethod -Exactly 2 } } Context "When a rate limit quota is exhausted" { $script:pesterHasRetried = $false Mock Invoke-RestMethod { try { if (!$pesterHasRetried) { $errorDetails = '{"code": 1, "message": "BadRequest", "more_info": "", "status": 429}' $statusCode = 429 $response = New-Object System.Net.Http.HttpResponseMessage $statusCode $response.Headers.Add('X-RateLimit-Reset', [datetime]::UtcNow.AddSeconds(1).ToFileTimeUtc()) $response.Headers.Add('X-RateLimit-Remaining', "0") $exception = New-Object Microsoft.PowerShell.Commands.HttpResponseException "$statusCode ($($response.ReasonPhrase))", $response $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidOperation $errorID = 'WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand' $targetObject = $null $errorRecord = New-Object Management.Automation.ErrorRecord $exception, $errorID, $errorCategory, $targetObject $errorRecord.ErrorDetails = $errorDetails throw $errorRecord } else { return @{ StatusCode = 200 } } } finally { $script:pesterHasRetried = $true } } It "Should wait until the time speciied in the 'X-RateLimit-Reset' response header" { # Act $result = Invoke-GitHubRestMethod @mockSplat # Assert $result | Should -Not -BeNullOrEmpty $result.StatusCode | Should -Be 200 Assert-MockCalled Invoke-RestMethod -Exactly 2 } } Context "When rate limit is exceeded with no retry context" { $script:pesterRetryCount = 0 Mock Invoke-RestMethod { try { if ($pesterRetryCount -lt 3) { $errorDetails = '{"code": 1, "message": "BadRequest", "more_info": "", "status": 429}' $statusCode = 429 $response = New-Object System.Net.Http.HttpResponseMessage $statusCode $exception = New-Object Microsoft.PowerShell.Commands.HttpResponseException "$statusCode ($($response.ReasonPhrase))", $response $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidOperation $errorID = 'WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand' $targetObject = $null $errorRecord = New-Object Management.Automation.ErrorRecord $exception, $errorID, $errorCategory, $targetObject $errorRecord.ErrorDetails = $errorDetails throw $errorRecord } else { return @{ StatusCode = 200 } } } finally { $script:pesterRetryCount++ } } It "Should implement an exponential back-off strategy" { # Act $startTime = Get-Date $result = Invoke-GitHubRestMethod @mockSplat -InitialBackOffSeconds 1 # Assert $result | Should -Not -BeNullOrEmpty $result.StatusCode | Should -Be 200 $stopTime = Get-Date $elapsedTime = $stopTime - $startTime $elapsedTime.TotalSeconds | Should -BeGreaterThan 4 } } } |