Public/Tests/Invoke-ConfluenceRequest.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.confluence.psd1') -Force
}

AfterAll {
    Remove-Module -Name tcs.confluence -Force -ErrorAction SilentlyContinue
}


Describe 'Invoke-ConfluenceRequest without a context' {
    BeforeAll {
        Import-Module -Name (Join-Path -Path $ModuleRoot -ChildPath 'tcs.confluence.psd1') -Force
        Mock -ModuleName tcs.confluence Invoke-WebRequest { }
    }

    It 'Reports an error and sends nothing' {
        $result = Invoke-ConfluenceRequest -Method GET -Resource pages -ErrorAction SilentlyContinue -ErrorVariable requestError
        $result | Should -BeNullOrEmpty
        "$requestError" | Should -Match 'Set-ConfluenceContext'
        Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 0 -Exactly
    }

    It 'Requires -URIPath or -Resource' {
        Invoke-ConfluenceRequest -Method GET -ErrorAction SilentlyContinue -ErrorVariable requestError
        "$requestError" | Should -Match 'URIPath or -Resource'
    }
}

Describe 'Invoke-ConfluenceRequest' {
    BeforeAll {
        Set-ConfluenceContext -ConfluenceUrl 'https://contoso.atlassian.net' -Username 'user@contoso.com' -PersonalAccessToken 'secret-token-value'
        $expectedAuth = 'Basic ' + [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes('user@contoso.com:secret-token-value'))
        function Get-DecodedUri($Uri) { [System.Net.WebUtility]::UrlDecode($Uri.OriginalString) }
    }

    BeforeEach {
        Mock -ModuleName tcs.confluence Invoke-WebRequest {
            [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '{"results":[{"id":"1","title":"Page"}],"_links":{}}' }
        }
    }

    Context 'Endpoint construction' {
        It 'Builds <Expected> from <Description>' -ForEach @(
            @{ Description = 'v2 pages'; Params = @{ Resource = 'pages' }; Expected = 'https://contoso.atlassian.net/wiki/api/v2/pages' }
            @{ Description = 'v2 page by id'; Params = @{ Resource = 'pages'; Id = '12345' }; Expected = 'https://contoso.atlassian.net/wiki/api/v2/pages/12345' }
            @{ Description = 'v2 spaces'; Params = @{ Resource = 'spaces' }; Expected = 'https://contoso.atlassian.net/wiki/api/v2/spaces' }
            @{ Description = 'v1 pages'; Params = @{ Resource = 'pages'; ApiVersion = 1 }; Expected = 'https://contoso.atlassian.net/wiki/rest/api/content' }
            @{ Description = 'v1 spaces'; Params = @{ Resource = 'spaces'; ApiVersion = 1 }; Expected = 'https://contoso.atlassian.net/wiki/rest/api/space' }
            @{ Description = 'legacy /pages path'; Params = @{ URIPath = '/pages' }; Expected = 'https://contoso.atlassian.net/wiki/api/v2/pages' }
            @{ Description = 'explicit path'; Params = @{ URIPath = 'wiki/rest/api/content/5' }; Expected = 'https://contoso.atlassian.net/wiki/rest/api/content/5' }
        ) {
            $null = Invoke-ConfluenceRequest -Method GET @Params
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -eq $Expected }
        }

        It 'Sends a wildcard page title search to the documented CQL endpoint content/search' {
            $null = Invoke-ConfluenceRequest -Method GET -Resource pages -Search 'Test*'
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter {
                (Get-DecodedUri $Uri) -eq 'https://contoso.atlassian.net/wiki/rest/api/content/search?cql=type = page AND title ~ "Test*"'
            }
        }

        It 'Sends a search on /content to content/search with an exact CQL match and escapes quotes' {
            $null = Invoke-ConfluenceRequest -Method GET -URIPath '/wiki/rest/api/content' -Search 'Say "hi"'
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter {
                (Get-DecodedUri $Uri) -eq 'https://contoso.atlassian.net/wiki/rest/api/content/search?cql=title = "Say \"hi\""'
            }
        }

        It 'Escapes a backslash before the quote so a title cannot break out of the CQL string' {
            $null = Invoke-ConfluenceRequest -Method GET -Resource pages -Search 'a\" OR space = "X*'
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter {
                (Get-DecodedUri $Uri) -eq 'https://contoso.atlassian.net/wiki/rest/api/content/search?cql=type = page AND title ~ "a\\\" OR space = \"X*"'
            }
        }

        It 'Adds the space to the CQL query of a search (<Space>)' -ForEach @(
            @{ Space = 'DOCS'; Clause = 'space = "DOCS"' }
            @{ Space = '42'; Clause = 'space.id = 42' }
        ) {
            $null = Invoke-ConfluenceRequest -Method GET -Resource pages -Search 'Run*' -Query @{ spaceKey = $Space }
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter {
                (Get-DecodedUri $Uri) -eq "https://contoso.atlassian.net/wiki/rest/api/content/search?cql=type = page AND $Clause AND title ~ `"Run*`""
            }
        }

        It 'URL-encodes query values' {
            $null = Invoke-ConfluenceRequest -Method GET -Resource pages -Query @{ title = 'A&B C' }
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter {
                $Uri.OriginalString -like '*title=A%26B*C' -and (Get-DecodedUri $Uri) -like '*title=A&B C'
            }
        }

        It 'Does not duplicate the API path when the context URL contains it' {
            Set-ConfluenceContext -ConfluenceUrl 'https://contoso.atlassian.net/wiki/api/v2/' -Username 'user@contoso.com' -PersonalAccessToken 'secret-token-value'
            $null = Invoke-ConfluenceRequest -Method GET -Resource pages
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -eq 'https://contoso.atlassian.net/wiki/api/v2/pages' }
        }
    }

    Context 'spaceKey handling for v2 pages' {
        BeforeEach {
            InModuleScope tcs.confluence { $script:ConfluenceSpaceIdCache = $null }
        }

        It 'Sends a numeric spaceKey as the documented space-id parameter without a lookup' {
            $null = Invoke-ConfluenceRequest -Method GET -Resource pages -Query @{ spaceKey = '12345' }
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -eq 'https://contoso.atlassian.net/wiki/api/v2/pages?space-id=12345' }
        }

        It 'Sends spaceId as space-id' {
            $null = Invoke-ConfluenceRequest -Method GET -Resource pages -Query @{ spaceId = '7' }
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -eq 'https://contoso.atlassian.net/wiki/api/v2/pages?space-id=7' }
        }

        It 'Resolves a space key to its id once and caches it' {
            Mock -ModuleName tcs.confluence Invoke-WebRequest -ParameterFilter { $Uri.OriginalString -like '*/spaces?keys=DOCS*' } {
                [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '{"results":[{"id":"98765","key":"DOCS"}]}' }
            }
            $query = @{ spaceKey = 'DOCS' }
            $null = Invoke-ConfluenceRequest -Method GET -Resource pages -Query $query
            $null = Invoke-ConfluenceRequest -Method GET -Resource pages -Query $query
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 2 -Exactly -ParameterFilter { $Uri.OriginalString -eq 'https://contoso.atlassian.net/wiki/api/v2/pages?space-id=98765' }
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -like '*/spaces?keys=DOCS*' }
            $query.ContainsKey('spaceKey') | Should -BeTrue -Because 'the caller''s hashtable must not be changed'
        }

        It 'Reports an unknown space key and does not send an unfiltered request' {
            Mock -ModuleName tcs.confluence Invoke-WebRequest -ParameterFilter { $Uri.OriginalString -like '*/spaces?keys=*' } {
                [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '{"results":[]}' }
            }
            { Invoke-ConfluenceRequest -Method GET -Resource pages -Query @{ spaceKey = 'NOPE' } -ErrorAction Stop } | Should -Throw -ExpectedMessage '*NOPE*'
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 0 -Exactly -ParameterFilter { $Uri.OriginalString -like '*/pages*' }
        }

        It 'Clears the cache when the context is set again' {
            InModuleScope tcs.confluence { $script:ConfluenceSpaceIdCache = New-Object -TypeName 'System.Collections.Generic.Dictionary[string,string]'; $script:ConfluenceSpaceIdCache['X'] = '1' }
            Set-ConfluenceContext -ConfluenceUrl 'https://contoso.atlassian.net' -Username 'user@contoso.com' -PersonalAccessToken 'secret-token-value'
            InModuleScope tcs.confluence { $script:ConfluenceSpaceIdCache } | Should -BeNullOrEmpty
        }
    }

    Context 'API version from the context' {
        AfterEach {
            Set-ConfluenceContext -ConfluenceUrl 'https://contoso.atlassian.net' -Username 'user@contoso.com' -PersonalAccessToken 'secret-token-value'
        }

        It 'Uses the v1 API for -Resource when the context was set with -ApiVersion v1' {
            Set-ConfluenceContext -ConfluenceUrl 'https://contoso.atlassian.net' -Username 'user@contoso.com' -PersonalAccessToken 'secret-token-value' -ApiVersion v1
            $null = Invoke-ConfluenceRequest -Method GET -Resource pages
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -eq 'https://contoso.atlassian.net/wiki/rest/api/content' }
        }

        It 'Lets -ApiVersion override the context' {
            Set-ConfluenceContext -ConfluenceUrl 'https://contoso.atlassian.net' -Username 'user@contoso.com' -PersonalAccessToken 'secret-token-value' -ApiVersion v1
            $null = Invoke-ConfluenceRequest -Method GET -Resource pages -ApiVersion 2
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -eq 'https://contoso.atlassian.net/wiki/api/v2/pages' }
        }

        It 'Keeps Get-ConfluencePage on the v2 API it is written for' {
            Set-ConfluenceContext -ConfluenceUrl 'https://contoso.atlassian.net' -Username 'user@contoso.com' -PersonalAccessToken 'secret-token-value' -ApiVersion v1
            $null = Get-ConfluencePage -PageId 5
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -eq 'https://contoso.atlassian.net/wiki/api/v2/pages/5' }
        }
    }

    Context 'Rate limiting' {
        BeforeEach {
            Mock -ModuleName tcs.confluence Start-Sleep { }
        }

        It 'Retries a 429 response after the Retry-After delay' {
            $script:calls = 0
            Mock -ModuleName tcs.confluence Invoke-WebRequest {
                $script:calls++
                if ($script:calls -eq 1) {
                    return [pscustomobject]@{ StatusCode = 429; StatusDescription = 'Too Many Requests'; Content = ''; Headers = @{ 'Retry-After' = @('3') } }
                }
                [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '{"results":[{"id":"1"}]}' }
            }
            $result = Invoke-ConfluenceRequest -Method GET -Resource pages -ErrorAction Stop
            @($result.Results).Count | Should -Be 1
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 2 -Exactly
            Should -Invoke -ModuleName tcs.confluence Start-Sleep -Times 1 -Exactly -ParameterFilter { $Milliseconds -eq 3000 }
        }

        It 'Gives up after four retries and reports the 429' {
            Mock -ModuleName tcs.confluence Invoke-WebRequest {
                [pscustomobject]@{ StatusCode = 429; StatusDescription = 'Too Many Requests'; Content = '' }
            }
            { Invoke-ConfluenceRequest -Method GET -Resource pages -ErrorAction Stop } | Should -Throw -ExpectedMessage '*429*'
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 5 -Exactly
            Should -Invoke -ModuleName tcs.confluence Start-Sleep -Times 4 -Exactly
        }
    }

    Context 'Authentication' {
        It 'Sends a Basic Authorization header built from the stored credential' {
            $null = Invoke-ConfluenceRequest -Method GET -Resource pages
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Headers.Authorization -eq $expectedAuth }
        }

        It 'Never writes the token or the header to the verbose stream' {
            $verbose = Invoke-ConfluenceRequest -Method GET -Resource pages -Query @{ spaceKey = '1' } -Verbose 4>&1 | Where-Object { $_ -is [System.Management.Automation.VerboseRecord] }
            $text = ($verbose | ForEach-Object { $_.Message }) -join "`n"
            $text | Should -Not -BeNullOrEmpty
            $text | Should -Not -Match 'secret-token-value'
            $text | Should -Not -Match ([regex]::Escape($expectedAuth.Substring(6)))
        }
    }

    Context 'Responses' {
        It 'Returns the results and marks multi-result responses' {
            $result = Invoke-ConfluenceRequest -Method GET -Resource pages
            $result.MultiPage | Should -BeTrue
            @($result.Results).Count | Should -Be 1
            $result.Results[0].title | Should -Be 'Page'
        }

        It 'Wraps a single-object response in Results' {
            Mock -ModuleName tcs.confluence Invoke-WebRequest { [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '{"id":"7","title":"One"}' } }
            $result = Invoke-ConfluenceRequest -Method GET -Resource pages -Id 7
            $result.MultiPage | Should -BeFalse
            $result.Results[0].id | Should -Be '7'
        }

        It 'Accepts an empty body (204 No Content)' {
            Mock -ModuleName tcs.confluence Invoke-WebRequest { [pscustomobject]@{ StatusCode = 204; StatusDescription = 'No Content'; Content = '' } }
            $result = Invoke-ConfluenceRequest -Method DELETE -Resource pages -Id 7 -ErrorAction Stop
            @($result.Results).Count | Should -Be 0
        }

        It 'Reports HTTP errors with the status and Confluence error titles' {
            Mock -ModuleName tcs.confluence Invoke-WebRequest {
                [pscustomobject]@{ StatusCode = 400; StatusDescription = 'Bad Request'; Content = '{"errors":[{"status":400,"title":"A page with this title already exists"}]}' }
            }
            { Invoke-ConfluenceRequest -Method POST -Resource pages -Body '{}' -ErrorAction Stop } | Should -Throw -ExpectedMessage '*400*already exists*'
        }

        It 'Reports transport failures' {
            Mock -ModuleName tcs.confluence Invoke-WebRequest { throw 'No such host is known.' }
            { Invoke-ConfluenceRequest -Method GET -Resource pages -ErrorAction Stop } | Should -Throw -ExpectedMessage '*No such host*'
        }

        It 'Sends the body as UTF-8 JSON' {
            $json = '{"title":"Gr' + [char]0x00F6 + 'sse"}'
            $null = Invoke-ConfluenceRequest -Method PUT -Resource pages -Id 1 -Body $json
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter {
                $Method -eq 'PUT' -and $ContentType -like 'application/json*' -and [System.Text.Encoding]::UTF8.GetString($Body) -eq $json
            }
        }

        It 'Rejects unsupported methods' {
            { Invoke-ConfluenceRequest -Method PATCH -Resource pages } | Should -Throw
        }
    }

    Context 'Pagination' {
        BeforeEach {
            Mock -ModuleName tcs.confluence Invoke-WebRequest {
                if ($Uri.OriginalString -like '*cursor=abc*') {
                    return [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '{"results":[{"id":"2"}],"_links":{"next":"/wiki/api/v2/pages?cursor=def"}}' }
                }
                if ($Uri.OriginalString -like '*cursor=def*') {
                    return [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '{"results":[{"id":"3"}],"_links":{}}' }
                }
                [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '{"results":[{"id":"1"}],"_links":{"next":"/wiki/api/v2/pages?cursor=abc"}}' }
            }
        }

        It 'Follows _links.next until there are no more pages' {
            $result = Invoke-ConfluenceRequest -Method GET -Resource pages
            @($result.Results).id | Should -Be @('1', '2', '3')
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -eq 'https://contoso.atlassian.net/wiki/api/v2/pages?cursor=abc' }
        }

        It 'Stops at -MaxQueryPages and warns that more results exist' {
            $result = Invoke-ConfluenceRequest -Method GET -Resource pages -MaxQueryPages 2 -WarningAction SilentlyContinue -WarningVariable pageWarning
            @($result.Results).Count | Should -Be 2
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 2 -Exactly
            "$pageWarning" | Should -BeLike '*more results*-All*'
        }

        It 'Does not warn when the last page was reached' {
            $null = Invoke-ConfluenceRequest -Method GET -Resource pages -MaxQueryPages 3 -WarningVariable pageWarning
            $pageWarning | Should -BeNullOrEmpty
        }

        It 'Reads every page with -All' {
            $result = Invoke-ConfluenceRequest -Method GET -Resource pages -MaxQueryPages 1 -All
            @($result.Results).id | Should -Be @('1', '2', '3')
        }

        It 'Resolves v1 next links against the /wiki context path' {
            Mock -ModuleName tcs.confluence Invoke-WebRequest -ParameterFilter { $Uri.OriginalString -like '*/rest/api/content?limit=1' } {
                [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '{"results":[{"id":"1"}],"_links":{"next":"/rest/api/content?limit=1&start=1"}}' }
            }
            $null = Invoke-ConfluenceRequest -Method GET -Resource pages -ApiVersion 1 -Query @{ limit = 1 } -MaxQueryPages 2 -WarningAction SilentlyContinue
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -eq 'https://contoso.atlassian.net/wiki/rest/api/content?limit=1&start=1' }
        }

        It 'Does not follow a next link to another host' {
            Mock -ModuleName tcs.confluence Invoke-WebRequest {
                [pscustomobject]@{ StatusCode = 200; StatusDescription = 'OK'; Content = '{"results":[{"id":"1"}],"_links":{"next":"https://attacker.example/steal"}}' }
            }
            $result = Invoke-ConfluenceRequest -Method GET -Resource pages -WarningAction SilentlyContinue -WarningVariable pageWarning
            @($result.Results).Count | Should -Be 1
            $pageWarning | Should -Not -BeNullOrEmpty
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly
        }

        It 'Does not paginate non-GET requests' {
            $null = Invoke-ConfluenceRequest -Method POST -Resource pages -Body '{}'
            Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly
        }
    }
}