Public/Tests/Invoke-JiraRequest.Tests.ps1
|
BeforeAll { $env:TCS_CONFIG_ROOT = Join-Path -Path $TestDrive -ChildPath 'config' $env:TCS_SKIP_UPDATE_CHECK = '1' $env:TCS_TELEMETRY_OPTOUT = '1' $ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent Import-Module -Name (Join-Path -Path $ModuleRoot -ChildPath 'tcs.jira.psd1') -Force $Token = 'tok-SECRET-123' $EncodedPair = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("user@contoso.com:$Token")) $Base = 'https://contoso.atlassian.net' function New-HttpError { param ([int]$StatusCode, [string]$Details, [hashtable]$Headers = @{}) $exception = New-Object -TypeName System.Exception -ArgumentList "Response status code does not indicate success: $StatusCode" $exception | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = $StatusCode; Headers = $Headers }) $record = New-Object -TypeName System.Management.Automation.ErrorRecord -ArgumentList $exception, 'HttpError', ([System.Management.Automation.ErrorCategory]::InvalidOperation), $null if ($Details) { $record.ErrorDetails = New-Object -TypeName System.Management.Automation.ErrorDetails -ArgumentList $Details } $record } } AfterAll { Remove-Module -Name tcs.jira -Force -ErrorAction SilentlyContinue } Describe 'Invoke-JiraRequest' { BeforeEach { Set-JiraContext -JiraUrl "$Base/" -Username 'user@contoso.com' -PersonalAccessToken $Token Mock -ModuleName tcs.jira Start-Sleep { } } Context 'Context checks and parameter validation' { It 'Throws when Set-JiraContext has not been run' { InModuleScope tcs.jira { $script:JiraContext = $null $script:JiraCredential = $null } { Invoke-JiraRequest -Method Get -Resource issue -Id 'PROJ-1' } | Should -Throw '*Set-JiraContext*' } It 'Throws when no path, resource or JQL is given' { { Invoke-JiraRequest -Method Get } | Should -Throw '*-URIPath*' } It 'Rejects an unknown HTTP method' { { Invoke-JiraRequest -Method 'Fetch' -Resource issue } | Should -Throw } } Context 'Request construction' { BeforeEach { Mock -ModuleName tcs.jira Invoke-RestMethod { [pscustomobject]@{ key = 'PROJ-1' } } } It 'Builds the platform API URL, method and headers' { $result = Invoke-JiraRequest -Method Get -Resource issue -Id 'PROJ-1' $result.key | Should -Be 'PROJ-1' Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Uri -eq "$Base/rest/api/3/issue/PROJ-1" -and $Method -eq 'Get' -and $Headers['Authorization'] -ceq "Basic $EncodedPair" -and $Headers['Accept'] -eq 'application/json' -and -not $Headers.ContainsKey('ContentType') -and $ContentType -eq 'application/json; charset=utf-8' -and $null -eq $Body } } It 'Sends Service Management paths to /rest/servicedeskapi' { $null = Invoke-JiraRequest -Method Get -URIPath '/servicedeskapi/request/SD-1' Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Uri -eq "$Base/rest/servicedeskapi/request/SD-1" } } It 'Uses paths that already start with /rest/ unchanged' { $null = Invoke-JiraRequest -Method Get -URIPath '/rest/agile/1.0/board' Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Uri -eq "$Base/rest/agile/1.0/board" } } It 'Adds a leading slash to relative paths' { $null = Invoke-JiraRequest -Method Get -URIPath 'myself' Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Uri -eq "$Base/rest/api/3/myself" } } It 'Sends the body with POST' { $null = Invoke-JiraRequest -Method Post -Resource issue -Body '{"fields":{}}' Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Uri -eq "$Base/rest/api/3/issue" -and $Method -eq 'Post' -and $Body -eq '{"fields":{}}' } } It 'Encodes -Id as one path segment so it cannot change the path or add a query' { $null = Invoke-JiraRequest -Method Get -Resource issue -Id 'A-1?expand=changelog#x' $null = Invoke-JiraRequest -Method Get -Resource issue -Id 'A-1/../../myself' Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -eq "$Base/rest/api/3/issue/A-1%3Fexpand%3Dchangelog%23x" } Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -eq "$Base/rest/api/3/issue/A-1%2F..%2F..%2Fmyself" } } It 'Converts a hashtable body to JSON and sends a string body unchanged' { $null = Invoke-JiraRequest -Method Post -Resource issue -Body @{ fields = @{ summary = 'S'; labels = @('a') } } Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Body -is [string] -and ($Body | ConvertFrom-Json).fields.summary -eq 'S' -and (@(($Body | ConvertFrom-Json).fields.labels) -join ',') -eq 'a' } } It 'Converts deeply nested bodies without truncation' { $deep = @{ l1 = @{ l2 = @{ l3 = @{ l4 = @{ l5 = @{ l6 = @{ l7 = @{ l8 = @{ l9 = @{ l10 = @{ l11 = 'deep' } } } } } } } } } } } $null = Invoke-JiraRequest -Method Put -Resource issue -Id 'PROJ-1' -Body $deep Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { ($Body | ConvertFrom-Json).l1.l2.l3.l4.l5.l6.l7.l8.l9.l10.l11 -eq 'deep' } } It 'Sends -Resource search to the enhanced JQL search endpoint' { $null = Invoke-JiraRequest -Method Get -Resource search -Query @{ jql = 'project = P' } Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -like "$Base/rest/api/3/search/jql?*" } { Invoke-JiraRequest -Method Get -Resource search -Id 'x' } | Should -Throw '*-Id*' } It 'URL-encodes query parameters and does not change the caller hashtable' { $query = @{ 'a b' = 'c&d=e' } $null = Invoke-JiraRequest -Method Get -URIPath '/project/search' -Query $query Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -eq "$Base/rest/api/3/project/search?a%20b=c%26d%3De" } $query.Keys.Count | Should -Be 1 } It 'Does not write the token to the verbose stream' { $verbose = Invoke-JiraRequest -Method Get -Resource issue -Id 'PROJ-1' -Verbose 4>&1 | Where-Object { $_ -is [System.Management.Automation.VerboseRecord] } | Out-String $verbose | Should -Match 'rest/api/3/issue/PROJ-1' $verbose | Should -Not -Match ([regex]::Escape($Token)) $verbose | Should -Not -Match ([regex]::Escape($EncodedPair)) } } Context 'Search and pagination' { It 'Uses the enhanced JQL search endpoint and unwraps issues across pages' { Mock -ModuleName tcs.jira Invoke-RestMethod { if ($Uri -match 'nextPageToken=') { [pscustomobject]@{ issues = @([pscustomobject]@{ key = 'P-3' }); isLast = $true } } else { [pscustomobject]@{ issues = @([pscustomobject]@{ key = 'P-1' }, [pscustomobject]@{ key = 'P-2' }); nextPageToken = 'tok/2'; isLast = $false } } } $query = @{ maxResults = 2 } $result = @(Invoke-JiraRequest -Method Get -JQL 'project = P AND status = "To Do"' -Query $query) ($result | ForEach-Object key) -join ',' | Should -Be 'P-1,P-2,P-3' Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 2 -Exactly Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { # OriginalString: [uri].ToString() would unescape the query $sentUri = $Uri.OriginalString $sentUri -like "$Base/rest/api/3/search/jql?*" -and $sentUri -match 'jql=project%20%3D%20P%20AND%20status%20%3D%20%22To%20Do%22' -and $sentUri -match 'fields=(%2A|\*)navigable' -and $sentUri -match 'maxResults=2' -and $sentUri -notmatch 'nextPageToken' } Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -match 'nextPageToken=tok%2F2' } $query.ContainsKey('jql') | Should -BeFalse } It 'Keeps fields requested by the caller' { Mock -ModuleName tcs.jira Invoke-RestMethod { [pscustomobject]@{ issues = @(); isLast = $true } } $null = Invoke-JiraRequest -Method Get -JQL 'project = P' -Query @{ fields = 'summary' } Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Uri -match 'fields=summary' -and $Uri -notmatch 'navigable' } } It 'Stops at -MaxQueryPages and warns' { Mock -ModuleName tcs.jira Invoke-RestMethod { [pscustomobject]@{ issues = @([pscustomobject]@{ key = 'P-1' }); nextPageToken = 'more'; isLast = $false } } $result = @(Invoke-JiraRequest -Method Get -JQL 'project = P' -MaxQueryPages 3 -WarningVariable warnings -WarningAction SilentlyContinue) $result.Count | Should -Be 3 Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 3 -Exactly $warnings | Should -Not -BeNullOrEmpty } It 'Unwraps multi-value pages and follows nextPage links on the same site' { Mock -ModuleName tcs.jira Invoke-RestMethod { if ($Uri -match 'startAt=50') { [pscustomobject]@{ startAt = 50; maxResults = 50; isLast = $true; values = @([pscustomobject]@{ key = 'C' }) } } else { [pscustomobject]@{ startAt = 0; maxResults = 50; isLast = $false; values = @([pscustomobject]@{ key = 'A' }, [pscustomobject]@{ key = 'B' }); nextPage = 'https://contoso.atlassian.net/rest/api/3/project/search?startAt=50' } } } $result = @(Invoke-JiraRequest -Method Get -URIPath '/project/search') $result.Count | Should -Be 3 ($result | ForEach-Object key) -join ',' | Should -Be 'A,B,C' Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 2 -Exactly Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Uri -eq 'https://contoso.atlassian.net/rest/api/3/project/search?startAt=50' } } It 'Does not follow nextPage when the page says it is the last one' { Mock -ModuleName tcs.jira Invoke-RestMethod { [pscustomobject]@{ isLast = $true; values = @(1, 2); nextPage = 'https://contoso.atlassian.net/rest/api/3/project/search?startAt=2' } } $result = @(Invoke-JiraRequest -Method Get -URIPath '/project/search') $result.Count | Should -Be 2 Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly } It 'Unwraps Service Management pages and follows _links.next until isLastPage' { Mock -ModuleName tcs.jira Invoke-RestMethod { if ($Uri -match 'start=2') { [pscustomobject]@{ size = 1; start = 2; limit = 2; isLastPage = $true; values = @([pscustomobject]@{ id = '3' }); _links = [pscustomobject]@{ base = 'https://contoso.atlassian.net/rest/servicedeskapi' } } } else { [pscustomobject]@{ size = 2; start = 0; limit = 2; isLastPage = $false; values = @([pscustomobject]@{ id = '1' }, [pscustomobject]@{ id = '2' }); _links = [pscustomobject]@{ next = 'https://contoso.atlassian.net/rest/servicedeskapi/servicedesk?start=2&limit=2' } } } } $result = @(Invoke-JiraRequest -Method Get -URIPath '/servicedeskapi/servicedesk') ($result | ForEach-Object id) -join ',' | Should -Be '1,2,3' Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 2 -Exactly Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Uri -eq 'https://contoso.atlassian.net/rest/servicedeskapi/servicedesk?start=2&limit=2' } } It 'Stops Service Management paging at -MaxQueryPages and warns' { Mock -ModuleName tcs.jira Invoke-RestMethod { [pscustomobject]@{ isLastPage = $false; values = @('a'); _links = [pscustomobject]@{ next = 'https://contoso.atlassian.net/rest/servicedeskapi/request?start=1' } } } $result = @(Invoke-JiraRequest -Method Get -URIPath '/servicedeskapi/request' -MaxQueryPages 2 -WarningVariable warnings -WarningAction SilentlyContinue) $result.Count | Should -Be 2 Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 2 -Exactly $warnings | Should -Not -BeNullOrEmpty } It 'Does not follow a Service Management link to another host' { Mock -ModuleName tcs.jira Invoke-RestMethod { [pscustomobject]@{ isLastPage = $false; values = @('a'); _links = [pscustomobject]@{ next = 'https://evil.example.com/rest/servicedeskapi/request?start=1' } } } $null = Invoke-JiraRequest -Method Get -URIPath '/servicedeskapi/request' -WarningAction SilentlyContinue Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly } It 'Returns responses with an issues property unchanged outside search (bulk create errors)' { Mock -ModuleName tcs.jira Invoke-RestMethod { [pscustomobject]@{ issues = @([pscustomobject]@{ id = '1'; key = 'P-1' }) errors = @([pscustomobject]@{ status = 400; failedElementNumber = 1; elementErrors = [pscustomobject]@{ errors = [pscustomobject]@{ summary = 'required' } } }) } } $result = @(Invoke-JiraRequest -Method Post -URIPath '/issue/bulk' -Body @{ issueUpdates = @() }) $result.Count | Should -Be 1 @($result[0].issues).Count | Should -Be 1 @($result[0].errors).Count | Should -Be 1 $result[0].errors[0].failedElementNumber | Should -Be 1 } It 'Returns the untouched search response with -Raw and makes one request' { Mock -ModuleName tcs.jira Invoke-RestMethod { [pscustomobject]@{ issues = @([pscustomobject]@{ key = 'P-1' }); nextPageToken = 'more'; isLast = $false; names = [pscustomobject]@{ summary = 'Summary' } } } $result = @(Invoke-JiraRequest -Method Get -JQL 'project = P' -Raw) $result.Count | Should -Be 1 $result[0].nextPageToken | Should -Be 'more' $result[0].names.summary | Should -Be 'Summary' $result[0].issues[0].key | Should -Be 'P-1' Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly } It 'Returns an untouched page with -Raw' { Mock -ModuleName tcs.jira Invoke-RestMethod { [pscustomobject]@{ isLast = $false; values = @(1, 2); nextPage = 'https://contoso.atlassian.net/rest/api/3/project/search?startAt=2' } } $result = @(Invoke-JiraRequest -Method Get -URIPath '/project/search' -Raw) $result.Count | Should -Be 1 @($result[0].values).Count | Should -Be 2 Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly } It 'Does not send credentials to a nextPage link on another host' { Mock -ModuleName tcs.jira Invoke-RestMethod { [pscustomobject]@{ values = @(1); nextPage = 'https://evil.example.com/steal' } } $null = Invoke-JiraRequest -Method Get -URIPath '/project/search' -WarningAction SilentlyContinue Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 0 -Exactly -ParameterFilter { $Uri -like 'https://evil*' } } It 'Returns the elements of a JSON array response' { Mock -ModuleName tcs.jira Invoke-RestMethod { , @([pscustomobject]@{ accountId = 'a' }, [pscustomobject]@{ accountId = 'b' }) } $result = @(Invoke-JiraRequest -Method Get -URIPath '/user/search' -Query @{ query = 'x' }) $result.Count | Should -Be 2 $result[1].accountId | Should -Be 'b' } It 'Returns nothing for an empty (204) response' { Mock -ModuleName tcs.jira Invoke-RestMethod { '' } $result = Invoke-JiraRequest -Method Put -Resource issue -Id 'PROJ-1' -Body '{}' $result | Should -BeNullOrEmpty } } Context 'Error handling' { It 'Retries HTTP 503 and 429 responses, then returns the result' { $script:calls = 0 Mock -ModuleName tcs.jira Invoke-RestMethod { $script:calls++ if ($script:calls -eq 1) { throw (New-HttpError -StatusCode 503) } if ($script:calls -eq 2) { throw (New-HttpError -StatusCode 429) } [pscustomobject]@{ key = 'PROJ-1' } } $result = Invoke-JiraRequest -Method Get -Resource issue -Id 'PROJ-1' -WarningAction SilentlyContinue $result.key | Should -Be 'PROJ-1' Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 3 -Exactly Should -Invoke Start-Sleep -ModuleName tcs.jira -Times 2 -Exactly } It 'Does not retry a POST on HTTP 503, so an issue is never created twice' { Mock -ModuleName tcs.jira Invoke-RestMethod { throw (New-HttpError -StatusCode 503) } { Invoke-JiraRequest -Method Post -Resource issue -Body @{ fields = @{} } -WarningAction SilentlyContinue } | Should -Throw '*HTTP status code: 503*' Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly Should -Invoke Start-Sleep -ModuleName tcs.jira -Times 0 -Exactly } It 'Does not retry a POST on HTTP 500 or a PATCH on HTTP 502' { Mock -ModuleName tcs.jira Invoke-RestMethod { throw (New-HttpError -StatusCode 500) } { Invoke-JiraRequest -Method Post -URIPath '/issue/PROJ-1/comment' -Body '{}' } | Should -Throw '*HTTP status code: 500*' Mock -ModuleName tcs.jira Invoke-RestMethod { throw (New-HttpError -StatusCode 502) } { Invoke-JiraRequest -Method Patch -URIPath '/thing' -Body '{}' } | Should -Throw '*HTTP status code: 502*' Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 2 -Exactly } It 'Retries a POST on HTTP 429' { $script:calls = 0 Mock -ModuleName tcs.jira Invoke-RestMethod { $script:calls++ if ($script:calls -eq 1) { throw (New-HttpError -StatusCode 429) } [pscustomobject]@{ key = 'PROJ-2' } } $result = Invoke-JiraRequest -Method Post -Resource issue -Body '{}' -WarningAction SilentlyContinue $result.key | Should -Be 'PROJ-2' Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 2 -Exactly } It 'Retries PUT and DELETE on HTTP 5xx' { Mock -ModuleName tcs.jira Invoke-RestMethod { throw (New-HttpError -StatusCode 500) } { Invoke-JiraRequest -Method Put -Resource issue -Id 'PROJ-1' -Body '{}' -WarningAction SilentlyContinue } | Should -Throw { Invoke-JiraRequest -Method Delete -Resource issue -Id 'PROJ-1' -WarningAction SilentlyContinue } | Should -Throw Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 6 -Exactly } It 'Waits for the Retry-After header' { $script:calls = 0 Mock -ModuleName tcs.jira Invoke-RestMethod { $script:calls++ if ($script:calls -eq 1) { throw (New-HttpError -StatusCode 429 -Headers @{ 'Retry-After' = '7' }) } [pscustomobject]@{ key = 'PROJ-1' } } $null = Invoke-JiraRequest -Method Get -Resource issue -Id 'PROJ-1' -WarningAction SilentlyContinue Should -Invoke Start-Sleep -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Seconds -eq 7 } } It 'Backs off 2 then 4 seconds without Retry-After' { Mock -ModuleName tcs.jira Invoke-RestMethod { throw (New-HttpError -StatusCode 503) } { Invoke-JiraRequest -Method Get -Resource issue -Id 'PROJ-1' -WarningAction SilentlyContinue } | Should -Throw Should -Invoke Start-Sleep -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Seconds -eq 2 } Should -Invoke Start-Sleep -ModuleName tcs.jira -Times 1 -Exactly -ParameterFilter { $Seconds -eq 4 } } It 'Gives up after three attempts' { Mock -ModuleName tcs.jira Invoke-RestMethod { throw (New-HttpError -StatusCode 503) } { Invoke-JiraRequest -Method Get -Resource issue -Id 'PROJ-1' -WarningAction SilentlyContinue } | Should -Throw '*HTTP status code: 503*' Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 3 -Exactly } It 'Does not retry client errors and includes the status and Jira details' { Mock -ModuleName tcs.jira Invoke-RestMethod { throw (New-HttpError -StatusCode 404 -Details '{"errorMessages":["Issue does not exist"]}') } $caught = $null try { Invoke-JiraRequest -Method Get -Resource issue -Id 'PROJ-404' } catch { $caught = $_ } $caught | Should -Not -BeNullOrEmpty $caught.FullyQualifiedErrorId | Should -BeLike 'JiraRequestFailed*' $caught.Exception.Message | Should -Match 'HTTP status code: 404' $caught.Exception.Message | Should -Match 'Issue does not exist' $caught.Exception.Message | Should -Match 'PROJ-404' $caught.Exception.InnerException | Should -Not -BeNullOrEmpty Should -Invoke Invoke-RestMethod -ModuleName tcs.jira -Times 1 -Exactly Should -Invoke Start-Sleep -ModuleName tcs.jira -Times 0 -Exactly } It 'Never includes the token in the error' { Mock -ModuleName tcs.jira Invoke-RestMethod { throw (New-HttpError -StatusCode 401 -Details 'Unauthorized') } $caught = $null try { Invoke-JiraRequest -Method Get -Resource issue -Id 'PROJ-1' -Verbose 4>$null } catch { $caught = $_ } $text = ($caught | Out-String) + $caught.Exception.ToString() $text | Should -Not -Match ([regex]::Escape($Token)) $text | Should -Not -Match ([regex]::Escape($EncodedPair)) } } } |