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 'Switches a wildcard title search to the v1 CQL 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?cql=title ~ "Test*"' } } It 'Uses an exact CQL match without wildcards 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?cql=title = "Say \"hi\""' } } 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' { It 'Uses a numeric spaceKey as spaceId 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 -like '*spaceId=12345*' } } It 'Resolves a space key to its id' { 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 Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Uri.OriginalString -like '*/pages?spaceId=98765' } $query.ContainsKey('spaceKey') | Should -BeTrue -Because 'the caller''s hashtable must not be changed' } } 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' { $result = Invoke-ConfluenceRequest -Method GET -Resource pages -MaxQueryPages 2 @($result.Results).Count | Should -Be 2 Should -Invoke -ModuleName tcs.confluence Invoke-WebRequest -Times 2 -Exactly } 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 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 } } } |