tests/functions/Invoke-MtGitHubRequest.Tests.ps1
|
BeforeAll { Import-Module "$PSScriptRoot/../../Maester.psd1" -Force } Describe 'Invoke-MtGitHubRequest' { BeforeEach { InModuleScope Maester { $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $true Organization = 'myorg' ApiBaseUri = 'https://api.github.com' ApiVersion = '2022-11-28' } $__MtSession.GitHubAuthHeader = @{ Authorization = 'Bearer faketoken' } $__MtSession.GitHubCache = @{} } } AfterEach { InModuleScope Maester { $__MtSession.GitHubConnection = $null $__MtSession.GitHubAuthHeader = $null $__MtSession.GitHubCache = @{} } } Context 'Connection guard' { It 'Throws when GitHubConnection is null' { InModuleScope Maester { $__MtSession.GitHubConnection = $null { Invoke-MtGitHubRequest '/orgs/myorg' } | Should -Throw '*Connect-MtGitHub*' } } It 'Throws when Connected = $false' { InModuleScope Maester { $__MtSession.GitHubConnection = [PSCustomObject]@{ Connected = $false } { Invoke-MtGitHubRequest '/orgs/myorg' } | Should -Throw '*Connect-MtGitHub*' } } } Context 'Cache behavior' { BeforeEach { Mock Invoke-WebRequest -ModuleName Maester { [PSCustomObject]@{ Content = '{"login":"myorg"}'; Headers = @{} } } } It 'Returns cached result; Invoke-WebRequest called only once for two identical calls' { InModuleScope Maester { Invoke-MtGitHubRequest '/orgs/myorg' | Out-Null Invoke-MtGitHubRequest '/orgs/myorg' | Out-Null } Should -Invoke Invoke-WebRequest -ModuleName Maester -Exactly -Times 1 } It 'Cache key is ApiVersion|absoluteUri' { InModuleScope Maester { Invoke-MtGitHubRequest '/orgs/myorg' | Out-Null $expectedKey = '2022-11-28|https://api.github.com/orgs/myorg' $__MtSession.GitHubCache.ContainsKey($expectedKey) | Should -BeTrue } } It 'Stores result in cache on successful call (no -DisableCache)' { InModuleScope Maester { $result = Invoke-MtGitHubRequest '/orgs/myorg' $result.login | Should -Be 'myorg' $__MtSession.GitHubCache.Count | Should -Be 1 $cacheKey = '2022-11-28|https://api.github.com/orgs/myorg' $__MtSession.GitHubCache[$cacheKey].login | Should -Be 'myorg' } } It '-DisableCache bypasses existing cache entry and does NOT store result' { InModuleScope Maester { $cacheKey = '2022-11-28|https://api.github.com/orgs/myorg' $__MtSession.GitHubCache[$cacheKey] = [PSCustomObject]@{ login = 'cached-value' } $result = Invoke-MtGitHubRequest '/orgs/myorg' -DisableCache # Web request was made (bypassed cache) $result.login | Should -Be 'myorg' # Original cache entry is untouched (not overwritten) $__MtSession.GitHubCache[$cacheKey].login | Should -Be 'cached-value' } Should -Invoke Invoke-WebRequest -ModuleName Maester -Exactly -Times 1 } } Context 'Non-paginated request' { It 'Returns single object body without -Paginate' { Mock Invoke-WebRequest -ModuleName Maester { [PSCustomObject]@{ Content = '{"login":"myorg","plan":{"name":"enterprise"}}'; Headers = @{} } } InModuleScope Maester { $result = Invoke-MtGitHubRequest '/orgs/myorg' $result.login | Should -Be 'myorg' } } } Context 'Pagination' { It 'Follows Link rel=next until no next link; returns all items combined' { # [?&]page= matches ?page= or &page= but NOT per_page= (per_page contains 'page=' at offset 4) Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -notmatch '[?&]page=\d' } { [PSCustomObject]@{ Content = '[{"id":1},{"id":2}]' Headers = @{ 'Link' = '<https://api.github.com/orgs/myorg/members?page=2>; rel="next"' } } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '[?&]page=\d' } { [PSCustomObject]@{ Content = '[{"id":3}]'; Headers = @{} } } InModuleScope Maester { $result = Invoke-MtGitHubRequest '/orgs/myorg/members' -Paginate $result.Count | Should -Be 3 $result[2].id | Should -Be 3 } Should -Invoke Invoke-WebRequest -ModuleName Maester -Exactly -Times 2 } } Context 'Pagination of empty arrays' { It 'Returns an empty result (count 0) when the only page is `[]` with no next link' { Mock Invoke-WebRequest -ModuleName Maester { [PSCustomObject]@{ Content = '[]'; Headers = @{} } } InModuleScope Maester { $result = Invoke-MtGitHubRequest '/orgs/myorg/members' -Paginate @($result).Count | Should -Be 0 # Must not contain a spurious $null item (the regression case). @($result) -contains $null | Should -BeFalse } Should -Invoke Invoke-WebRequest -ModuleName Maester -Exactly -Times 1 } It 'Skips an empty intermediate page without contributing $null items' { Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -notmatch '[?&]page=\d' } { [PSCustomObject]@{ Content = '[]' Headers = @{ 'Link' = '<https://api.github.com/orgs/myorg/members?page=2>; rel="next"' } } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '[?&]page=\d' } { [PSCustomObject]@{ Content = '[{"id":7}]'; Headers = @{} } } InModuleScope Maester { $result = Invoke-MtGitHubRequest '/orgs/myorg/members' -Paginate @($result).Count | Should -Be 1 @($result)[0].id | Should -Be 7 @($result) -contains $null | Should -BeFalse } Should -Invoke Invoke-WebRequest -ModuleName Maester -Exactly -Times 2 } It 'Non-paginated `[]` response does not return a single $null item' { Mock Invoke-WebRequest -ModuleName Maester { [PSCustomObject]@{ Content = '[]'; Headers = @{} } } InModuleScope Maester { $result = Invoke-MtGitHubRequest '/orgs/myorg/members' @($result).Count | Should -Be 0 @($result) -contains $null | Should -BeFalse } } } Context 'Pagination cross-origin guard' { It 'Throws and stops paginating when next link is on a different origin' { Mock Invoke-WebRequest -ModuleName Maester { [PSCustomObject]@{ Content = '[{"id":1}]' Headers = @{ 'Link' = '<https://evil.example/orgs/myorg/members?page=2>; rel="next"' } } } InModuleScope Maester { { Invoke-MtGitHubRequest '/orgs/myorg/members' -Paginate } | Should -Throw '*outside the configured ApiBaseUri*' } Should -Invoke Invoke-WebRequest -ModuleName Maester -Exactly -Times 1 } It 'Allows a same-origin next link with a base path prefix (GHE-style /api/v3)' { InModuleScope Maester { $__MtSession.GitHubConnection.ApiBaseUri = 'https://ghe.example.com/api/v3' } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -notmatch '[?&]page=\d' } { [PSCustomObject]@{ Content = '[{"id":1}]' Headers = @{ 'Link' = '<https://ghe.example.com/api/v3/orgs/myorg/members?page=2>; rel="next"' } } } Mock Invoke-WebRequest -ModuleName Maester -ParameterFilter { $Uri -match '[?&]page=\d' } { [PSCustomObject]@{ Content = '[{"id":2}]'; Headers = @{} } } InModuleScope Maester { $result = Invoke-MtGitHubRequest '/orgs/myorg/members' -Paginate $result.Count | Should -Be 2 } Should -Invoke Invoke-WebRequest -ModuleName Maester -Exactly -Times 2 } } Context 'Rate limit handling' { It 'Emits verbose message when x-ratelimit-remaining is 0 on successful response' { Mock Invoke-WebRequest -ModuleName Maester { [PSCustomObject]@{ Content = '{"login":"myorg"}' Headers = @{ 'x-ratelimit-remaining' = '0'; 'x-ratelimit-reset' = '9999999999' } } } InModuleScope Maester { $allOutput = Invoke-MtGitHubRequest '/orgs/myorg' -Verbose 4>&1 $verboseMsgs = ($allOutput | Where-Object { $_ -is [System.Management.Automation.VerboseRecord] }).Message $verboseMsgs | Should -Match 'rate limit' } } It 'Throws on primary rate-limit exhaustion (HTTP 403, remaining = 0)' { Mock Invoke-WebRequest -ModuleName Maester { $fakeResp = [PSCustomObject]@{ StatusCode = 403 Headers = @{ 'x-ratelimit-remaining' = '0'; 'x-ratelimit-reset' = '9999999999' } } $ex = [System.Exception]::new('Forbidden') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp throw $ex } InModuleScope Maester { { Invoke-MtGitHubRequest '/orgs/myorg' } | Should -Throw '*rate limit*' } } It 'Rethrows ordinary 403 without rate-limit headers as the original error (not a rate-limit message)' { Mock Invoke-WebRequest -ModuleName Maester { $fakeResp = [PSCustomObject]@{ StatusCode = 403 Headers = @{} } $ex = [System.Exception]::new('Forbidden') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp throw $ex } InModuleScope Maester { $thrownMessage = $null try { Invoke-MtGitHubRequest '/orgs/myorg' } catch { $thrownMessage = $_.Exception.Message } $thrownMessage | Should -Match 'Forbidden' $thrownMessage | Should -Not -Match 'rate limit' } } It 'Rethrows original error when x-ratelimit-remaining is malformed (no parse exception)' { Mock Invoke-WebRequest -ModuleName Maester { $fakeResp = [PSCustomObject]@{ StatusCode = 403 Headers = @{ 'x-ratelimit-remaining' = 'not-a-number' } } $ex = [System.Exception]::new('Forbidden') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp throw $ex } InModuleScope Maester { $thrownMessage = $null try { Invoke-MtGitHubRequest '/orgs/myorg' } catch { $thrownMessage = $_.Exception.Message } $thrownMessage | Should -Match 'Forbidden' $thrownMessage | Should -Not -Match 'rate limit' $thrownMessage | Should -Not -Match 'Cannot convert' } } It 'Does not throw on successful response when x-ratelimit-reset is out of range for FromUnixTimeSeconds' { # 253402300800 is one second past the documented upper bound of FromUnixTimeSeconds # (max accepted = 253402300799 = 9999-12-31T23:59:59Z). A bogus reset epoch from # an upstream proxy must not raise ArgumentOutOfRangeException and mask the response. Mock Invoke-WebRequest -ModuleName Maester { [PSCustomObject]@{ Content = '{"login":"myorg"}' Headers = @{ 'x-ratelimit-remaining' = '0'; 'x-ratelimit-reset' = '253402300800' } } } InModuleScope Maester { { Invoke-MtGitHubRequest '/orgs/myorg' } | Should -Not -Throw $result = Invoke-MtGitHubRequest '/orgs/myorg' -DisableCache $result.login | Should -Be 'myorg' } } It 'Does not throw on successful response when x-ratelimit-remaining is malformed' { # Successful response body is valid; a malformed rate-limit header (e.g. an upstream # proxy rewriting the value) must not raise a parse exception that masks the response. Mock Invoke-WebRequest -ModuleName Maester { [PSCustomObject]@{ Content = '{"login":"myorg"}' Headers = @{ 'x-ratelimit-remaining' = 'not-a-number'; 'x-ratelimit-reset' = 'also-bogus' } } } InModuleScope Maester { { Invoke-MtGitHubRequest '/orgs/myorg' } | Should -Not -Throw $result = Invoke-MtGitHubRequest '/orgs/myorg' -DisableCache $result.login | Should -Be 'myorg' } } It 'Throws on secondary rate-limit (HTTP 429, retry-after header present)' { Mock Invoke-WebRequest -ModuleName Maester { $fakeResp = [PSCustomObject]@{ StatusCode = 429 Headers = @{ 'retry-after' = '30' } } $ex = [System.Exception]::new('Too Many Requests') Add-Member -InputObject $ex -MemberType NoteProperty -Name Response -Value $fakeResp throw $ex } InModuleScope Maester { { Invoke-MtGitHubRequest '/orgs/myorg' } | Should -Throw '*secondary rate limit*' } } } } |