From f86f5270baf41ca5b9f86e74229162273282246f Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Tue, 7 Jul 2026 22:41:38 +0200 Subject: [PATCH 01/21] =?UTF-8?q?=F0=9F=9A=80=20[Feature]:=20Decompose=20t?= =?UTF-8?q?he=20Confluence=20client=20into=20the=20PSModule=20source=20lay?= =?UTF-8?q?out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the first working version of the module by porting the generalized Confluence REST v2 client and decomposing it into the PSModule one-function-per-file source layout. - 35 public functions grouped by resource area (Auth, Config, API, Spaces, Site, Pages, BlogPosts, Folders, Comments, Labels, ContentProperties, Attachments, Restrictions, Users) - 4 private helpers (Auth, Config); module state in variables/private - Declares a dependency on the Context module via #Requires in header.ps1 (RequiredVersion 8.1.6), following the GitHub module pattern; credentials/config persist in the 'PSModule.Confluence' context vault - Removes the template scaffold placeholders; adds Connecting/Pages examples and a credential-free surface test plus a skipped integration scaffold that reads connection details from environment secrets - No tenant-specific data is included; examples use generic placeholders --- README.md | 68 +++---- examples/Connecting.ps1 | 48 +++++ examples/General.ps1 | 19 -- examples/Pages.ps1 | 34 ++++ src/finally.ps1 | 3 - .../Auth/Resolve-ConfluenceContext.ps1 | 38 ++++ .../private/Auth/Resolve-ConfluenceToken.ps1 | 21 +++ .../Config/ConvertTo-ConfluenceHashtable.ps1 | 28 +++ .../Config/Initialize-ConfluenceConfig.ps1 | 45 +++++ .../API/Invoke-ConfluenceRestMethod.ps1 | 168 ++++++++++++++++++ .../Attachments/Add-ConfluenceAttachment.ps1 | 39 ++++ .../Attachments/Get-ConfluenceAttachment.ps1 | 31 ++++ .../Remove-ConfluenceAttachment.ps1 | 25 +++ .../public/Auth/Connect-Confluence.ps1 | 92 ++++++++++ .../public/Auth/Disconnect-Confluence.ps1 | 31 ++++ .../public/Auth/Get-ConfluenceContext.ps1 | 35 ++++ .../BlogPosts/Get-ConfluenceBlogPost.ps1 | 34 ++++ .../public/Comments/Add-ConfluenceComment.ps1 | 41 +++++ .../public/Comments/Get-ConfluenceComment.ps1 | 23 +++ .../Comments/Remove-ConfluenceComment.ps1 | 25 +++ .../public/Config/Get-ConfluenceConfig.ps1 | 22 +++ .../public/Config/Set-ConfluenceConfig.ps1 | 28 +++ .../Get-ConfluenceContentProperty.ps1 | 33 ++++ .../Remove-ConfluenceContentProperty.ps1 | 30 ++++ .../Set-ConfluenceContentProperty.ps1 | 56 ++++++ .../public/Folders/Get-ConfluenceFolder.ps1 | 23 +++ .../public/Folders/New-ConfluenceFolder.ps1 | 40 +++++ .../Folders/Remove-ConfluenceFolder.ps1 | 25 +++ src/functions/public/Get-PSModuleTest.ps1 | 23 --- .../public/Labels/Add-ConfluenceLabel.ps1 | 44 +++++ .../public/Labels/Get-ConfluenceLabel.ps1 | 25 +++ .../public/Labels/Remove-ConfluenceLabel.ps1 | 29 +++ .../public/Pages/Get-ConfluenceDescendant.ps1 | 24 +++ .../public/Pages/Get-ConfluencePage.ps1 | 27 +++ .../public/Pages/Get-ConfluencePageChild.ps1 | 23 +++ .../Pages/Get-ConfluencePageVersion.ps1 | 23 +++ .../public/Pages/New-ConfluencePage.ps1 | 53 ++++++ .../public/Pages/Remove-ConfluencePage.ps1 | 45 +++++ .../public/Pages/Set-ConfluencePage.ps1 | 59 ++++++ .../Get-ConfluenceRestriction.ps1 | 29 +++ .../public/Site/Get-ConfluenceSiteInfo.ps1 | 45 +++++ .../public/Spaces/Get-ConfluenceSpace.ps1 | 41 +++++ .../Spaces/Get-ConfluenceSpacePermission.ps1 | 28 +++ .../Spaces/Get-ConfluenceSpaceProperty.ps1 | 30 ++++ .../Users/Get-ConfluenceCurrentUser.ps1 | 23 +++ src/header.ps1 | 43 ++++- src/init/initializer.ps1 | 3 - src/manifest.psd1 | 14 ++ src/scripts/loader.ps1 | 3 - src/variables/private/Confluence.ps1 | 9 + tests/Confluence.Tests.ps1 | 88 +++++++++ tests/PSModuleTest.Tests.ps1 | 5 - 52 files changed, 1740 insertions(+), 101 deletions(-) create mode 100644 examples/Connecting.ps1 delete mode 100644 examples/General.ps1 create mode 100644 examples/Pages.ps1 delete mode 100644 src/finally.ps1 create mode 100644 src/functions/private/Auth/Resolve-ConfluenceContext.ps1 create mode 100644 src/functions/private/Auth/Resolve-ConfluenceToken.ps1 create mode 100644 src/functions/private/Config/ConvertTo-ConfluenceHashtable.ps1 create mode 100644 src/functions/private/Config/Initialize-ConfluenceConfig.ps1 create mode 100644 src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 create mode 100644 src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 create mode 100644 src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 create mode 100644 src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 create mode 100644 src/functions/public/Auth/Connect-Confluence.ps1 create mode 100644 src/functions/public/Auth/Disconnect-Confluence.ps1 create mode 100644 src/functions/public/Auth/Get-ConfluenceContext.ps1 create mode 100644 src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 create mode 100644 src/functions/public/Comments/Add-ConfluenceComment.ps1 create mode 100644 src/functions/public/Comments/Get-ConfluenceComment.ps1 create mode 100644 src/functions/public/Comments/Remove-ConfluenceComment.ps1 create mode 100644 src/functions/public/Config/Get-ConfluenceConfig.ps1 create mode 100644 src/functions/public/Config/Set-ConfluenceConfig.ps1 create mode 100644 src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 create mode 100644 src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 create mode 100644 src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 create mode 100644 src/functions/public/Folders/Get-ConfluenceFolder.ps1 create mode 100644 src/functions/public/Folders/New-ConfluenceFolder.ps1 create mode 100644 src/functions/public/Folders/Remove-ConfluenceFolder.ps1 delete mode 100644 src/functions/public/Get-PSModuleTest.ps1 create mode 100644 src/functions/public/Labels/Add-ConfluenceLabel.ps1 create mode 100644 src/functions/public/Labels/Get-ConfluenceLabel.ps1 create mode 100644 src/functions/public/Labels/Remove-ConfluenceLabel.ps1 create mode 100644 src/functions/public/Pages/Get-ConfluenceDescendant.ps1 create mode 100644 src/functions/public/Pages/Get-ConfluencePage.ps1 create mode 100644 src/functions/public/Pages/Get-ConfluencePageChild.ps1 create mode 100644 src/functions/public/Pages/Get-ConfluencePageVersion.ps1 create mode 100644 src/functions/public/Pages/New-ConfluencePage.ps1 create mode 100644 src/functions/public/Pages/Remove-ConfluencePage.ps1 create mode 100644 src/functions/public/Pages/Set-ConfluencePage.ps1 create mode 100644 src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 create mode 100644 src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 create mode 100644 src/functions/public/Spaces/Get-ConfluenceSpace.ps1 create mode 100644 src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 create mode 100644 src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 create mode 100644 src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 delete mode 100644 src/init/initializer.ps1 create mode 100644 src/manifest.psd1 delete mode 100644 src/scripts/loader.ps1 create mode 100644 src/variables/private/Confluence.ps1 create mode 100644 tests/Confluence.Tests.ps1 delete mode 100644 tests/PSModuleTest.Tests.ps1 diff --git a/README.md b/README.md index 36e567a..dcae3d4 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@ # Confluence -A PowerShell module that interacts with Atlassian Confluence +Confluence is a PowerShell module for interacting with the Atlassian Confluence Cloud REST API, both interactively and in automation. -## Prerequisites - -This uses the following external resources: -- The [PSModule framework](https://github.com/PSModule) for building, testing and publishing the module. +It exposes pages, blog posts, folders, spaces, comments, labels, content properties, attachments, restrictions and more as PowerShell commands. +Every command is a thin wrapper over a single generic REST entry point (`Invoke-ConfluenceRestMethod`). Credentials and module configuration +are stored with the [Context](https://github.com/PSModule/Context) module, so you connect once and reuse the profile across sessions. ## Installation -To install the module from the PowerShell Gallery, you can use the following command: +Install the module from the PowerShell Gallery: ```powershell Install-PSResource -Name Confluence @@ -18,52 +17,33 @@ Import-Module -Name Confluence ## Usage -Here is a list of example that are typical use cases for the module. - -### Example 1: Greet an entity - -Provide examples for typical commands that a user would like to do with the module. +Connect with a scoped Atlassian API token, then call the resource commands. The token is kept as a `SecureString` in the Context vault. ```powershell -Greet-Entity -Name 'World' -Hello, World! +# Connect and store a reusable, named credential profile +$token = Read-Host -AsSecureString # a scoped Atlassian API token +Connect-Confluence -ApiBaseUri 'https://api.atlassian.com/ex/confluence/' -Username 'you@example.com' -Token $token -SpaceKey 'DOCS' + +# Work with content +$space = Get-ConfluenceSpace -Key 'DOCS' +$page = New-ConfluencePage -SpaceId $space.id -Title 'Release notes' -Body '

Hello

' +Set-ConfluencePage -PageId $page.id -Body '

Updated

' +Get-ConfluencePageChild -PageId $page.id ``` -### Example 2 - -Provide examples for typical commands that a user would like to do with the module. - -```powershell -Import-Module -Name PSModuleTemplate -``` - -### Find more examples - -To find more examples of how to use the module, please refer to the [examples](examples) folder. - -Alternatively, you can use the Get-Command -Module 'This module' to find more commands that are available in the module. -To find examples of each of the commands you can use Get-Help -Examples 'CommandName'. +See the [examples](examples) folder for more, including managing contexts and pages. ## Documentation -Link to further documentation if available, or describe where in the repository users can find more detailed documentation about -the module's functions and features. - -## Contributing - -Coder or not, you can contribute to the project! We welcome all contributions. - -### For Users +Documentation is published at [psmodule.io/Confluence](https://psmodule.io/Confluence/). -If you don't code, you still sit on valuable information that can make this project even better. If you experience that the -product does unexpected things, throw errors or is missing functionality, you can help by submitting bugs and feature requests. -Please see the issues tab on this project and submit a new issue that matches your needs. +Use PowerShell help and command discovery for module details: -### For Developers - -If you do code, we'd love to have your contributions. Please read the [Contribution guidelines](CONTRIBUTING.md) for more information. -You can either help by picking up an existing issue or submit a new one if you have an idea for a new feature or improvement. +```powershell +Get-Command -Module Confluence +Get-Help New-ConfluencePage -Examples +``` -## Acknowledgements +## Contributing -Here is a list of people and projects that helped this project in some way. +Issues and pull requests are welcome. Please use the repository issue tracker to report bugs, request features, or discuss improvements. diff --git a/examples/Connecting.ps1 b/examples/Connecting.ps1 new file mode 100644 index 0000000..808df53 --- /dev/null +++ b/examples/Connecting.ps1 @@ -0,0 +1,48 @@ +<# + .SYNOPSIS + Connect to Confluence and manage credential profiles (contexts). + + .DESCRIPTION + Confluence stores each connection as a named context in the Context vault. + The token is kept as a SecureString. One context is the default; any command + can target a specific context with -Context. +#> + +# Import the module +Import-Module -Name 'Confluence' + +### +### CONNECTING +### + +# Connect with a scoped Atlassian API token and store a reusable default profile. +$token = Read-Host -AsSecureString # a scoped Atlassian API token +Connect-Confluence -ApiBaseUri 'https://api.atlassian.com/ex/confluence/' -Username 'you@example.com' -Token $token -SpaceKey 'DOCS' + +# Store several sites/accounts under explicit names and select per call. +Connect-Confluence -ApiBaseUri 'https://api.atlassian.com/ex/confluence/' -Username 'you@example.com' -Token $token -Name 'sandbox' +Get-ConfluenceSpace -Key 'DOCS' -Context 'sandbox' + +### +### CONTEXTS / PROFILES +### + +# The default context +Get-ConfluenceContext + +# All stored contexts +Get-ConfluenceContext -ListAvailable + +### +### MODULE CONFIGURATION +### + +# Read and set module configuration (persisted in the same vault). +Get-ConfluenceConfig +Set-ConfluenceConfig -Name 'PerPage' -Value 50 + +### +### DISCONNECTING +### + +Disconnect-Confluence -Name 'sandbox' diff --git a/examples/General.ps1 b/examples/General.ps1 deleted file mode 100644 index e193423..0000000 --- a/examples/General.ps1 +++ /dev/null @@ -1,19 +0,0 @@ -<# - .SYNOPSIS - This is a general example of how to use the module. -#> - -# Import the module -Import-Module -Name 'PSModule' - -# Define the path to the font file -$FontFilePath = 'C:\Fonts\CodeNewRoman\CodeNewRomanNerdFontPropo-Regular.tff' - -# Install the font -Install-Font -Path $FontFilePath -Verbose - -# List installed fonts -Get-Font -Name 'CodeNewRomanNerdFontPropo-Regular' - -# Uninstall the font -Get-Font -Name 'CodeNewRomanNerdFontPropo-Regular' | Uninstall-Font -Verbose diff --git a/examples/Pages.ps1 b/examples/Pages.ps1 new file mode 100644 index 0000000..5dd0849 --- /dev/null +++ b/examples/Pages.ps1 @@ -0,0 +1,34 @@ +<# + .SYNOPSIS + Create, read, update and remove Confluence pages. + + .DESCRIPTION + Assumes a default context is already connected (see Connecting.ps1). +#> + +# Import the module +Import-Module -Name 'Confluence' + +# Resolve the target space +$space = Get-ConfluenceSpace -Key 'DOCS' + +# Create a page +$page = New-ConfluencePage -SpaceId $space.id -Title 'Release notes' -Body '

First draft

' + +# Read it back (storage format by default) +Get-ConfluencePage -PageId $page.id + +# Update the body (the version number is incremented automatically) +Set-ConfluencePage -PageId $page.id -Body '

Updated content

' + +# Add a child page, then list children and descendants +$child = New-ConfluencePage -SpaceId $space.id -Title 'Details' -ParentId $page.id -Body '

Nested

' +Get-ConfluencePageChild -PageId $page.id +Get-ConfluenceDescendant -PageId $page.id + +# Labels and comments +Add-ConfluenceLabel -PageId $page.id -Label 'release', 'published' +Add-ConfluenceComment -PageId $page.id -Body '

Looks good.

' + +# Remove the whole subtree (moves the pages to trash) +Remove-ConfluencePage -PageId $page.id -Recurse diff --git a/src/finally.ps1 b/src/finally.ps1 deleted file mode 100644 index d8fc207..0000000 --- a/src/finally.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -Write-Verbose '------------------------------' -Write-Verbose '--- THIS IS A LAST LOADER ---' -Write-Verbose '------------------------------' diff --git a/src/functions/private/Auth/Resolve-ConfluenceContext.ps1 b/src/functions/private/Auth/Resolve-ConfluenceContext.ps1 new file mode 100644 index 0000000..234c39f --- /dev/null +++ b/src/functions/private/Auth/Resolve-ConfluenceContext.ps1 @@ -0,0 +1,38 @@ +function Resolve-ConfluenceContext { + <# + .SYNOPSIS + Resolve a context argument into a usable credential-context object. + .DESCRIPTION + Accepts a context object/hashtable (returned as-is), a context name + (looked up in the vault), or nothing (falls back to the default context + recorded in the module configuration). + #> + [CmdletBinding()] + param( + # A context object, a context name, or $null for the default context. + [object]$Context + ) + + if ($null -ne $Context -and $Context -isnot [string]) { + return $Context + } + + Initialize-ConfluenceConfig + $id = if ([string]::IsNullOrEmpty([string]$Context)) { $script:Confluence.Config['DefaultContext'] } else { [string]$Context } + + if ([string]::IsNullOrEmpty($id)) { + throw 'No Confluence context was specified and no default context is set. Run Connect-Confluence first.' + } + + $resolved = $null + try { + $resolved = Get-Context -ID $id -Vault $script:Confluence.ContextVault -ErrorAction Stop + } catch { + $resolved = $null + } + + if (-not $resolved) { + throw "Confluence context '$id' was not found in vault '$($script:Confluence.ContextVault)'." + } + return $resolved +} diff --git a/src/functions/private/Auth/Resolve-ConfluenceToken.ps1 b/src/functions/private/Auth/Resolve-ConfluenceToken.ps1 new file mode 100644 index 0000000..b4dab02 --- /dev/null +++ b/src/functions/private/Auth/Resolve-ConfluenceToken.ps1 @@ -0,0 +1,21 @@ +function Resolve-ConfluenceToken { + <# + .SYNOPSIS + Return the plain-text API token from a SecureString or string. + .DESCRIPTION + The token is stored as a SecureString in the credential context; this + converts it to the plain text needed to build the Basic auth header. + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The token as a SecureString or a plain string. + [Parameter(Mandatory)] + [object]$Token + ) + + if ($Token -is [System.Security.SecureString]) { + return [System.Net.NetworkCredential]::new('', $Token).Password + } + return [string]$Token +} diff --git a/src/functions/private/Config/ConvertTo-ConfluenceHashtable.ps1 b/src/functions/private/Config/ConvertTo-ConfluenceHashtable.ps1 new file mode 100644 index 0000000..00c8d28 --- /dev/null +++ b/src/functions/private/Config/ConvertTo-ConfluenceHashtable.ps1 @@ -0,0 +1,28 @@ +function ConvertTo-ConfluenceHashtable { + <# + .SYNOPSIS + Convert a PSCustomObject (or hashtable) into a plain hashtable. + .DESCRIPTION + Used to turn the stored module-configuration context returned by + Get-Context into a mutable hashtable. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + # The object to convert. + [Parameter(Mandatory)] + [object]$InputObject + ) + + if ($InputObject -is [hashtable]) { + # Return a shallow clone so callers can mutate the result without altering + # the source hashtable (e.g. the built-in $script:Confluence.DefaultConfig). + return $InputObject.Clone() + } + + $result = @{} + foreach ($property in $InputObject.PSObject.Properties) { + $result[$property.Name] = $property.Value + } + return $result +} diff --git a/src/functions/private/Config/Initialize-ConfluenceConfig.ps1 b/src/functions/private/Config/Initialize-ConfluenceConfig.ps1 new file mode 100644 index 0000000..13095d8 --- /dev/null +++ b/src/functions/private/Config/Initialize-ConfluenceConfig.ps1 @@ -0,0 +1,45 @@ +function Initialize-ConfluenceConfig { + <# + .SYNOPSIS + Load the module configuration from the context vault into memory. + .DESCRIPTION + Loads the stored 'Module' configuration context, creating it from the + built-in defaults on first use. Missing default keys are backfilled. + #> + [CmdletBinding()] + param( + # Recreate the configuration from the built-in defaults. + [switch]$Force + ) + + if (-not $Force -and $null -ne $script:Confluence.Config) { + return + } + + $vault = $script:Confluence.ContextVault + $id = $script:Confluence.DefaultConfig.ID + + $stored = $null + if (-not $Force) { + try { + $stored = Get-Context -ID $id -Vault $vault -ErrorAction Stop + } catch { + $stored = $null + } + } + + if ($Force -or -not $stored) { + $config = ConvertTo-ConfluenceHashtable -InputObject $script:Confluence.DefaultConfig + } else { + $config = ConvertTo-ConfluenceHashtable -InputObject $stored + foreach ($key in $script:Confluence.DefaultConfig.Keys) { + if (-not $config.ContainsKey($key)) { + $config[$key] = $script:Confluence.DefaultConfig[$key] + } + } + } + + $config['ID'] = $id + $null = Set-Context -ID $id -Context $config -Vault $vault + $script:Confluence.Config = $config +} diff --git a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 new file mode 100644 index 0000000..2e5d6ab --- /dev/null +++ b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 @@ -0,0 +1,168 @@ +function Invoke-ConfluenceRestMethod { + <# + .SYNOPSIS + Call the Confluence REST API using a stored (or supplied) context. + .DESCRIPTION + The single generic entry point that every other Confluence function is + built on. It resolves the credential context, builds the HTTP Basic auth + header, sends the request, and surfaces API errors as terminating errors. + With -All it transparently follows v2 cursor pagination and returns the + aggregated 'results'. Error messages include Atlassian's + 'X-Failure-Category' response header when present (for example + FAILURE_CLIENT_SCOPE_CHECK), which distinguishes a missing-scope + rejection from other failures. Pass -Debug to emit the full request and + response (status, headers, and body); the Authorization header is + redacted. + .EXAMPLE + Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/spaces' -Query @{ limit = 1 } + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/intro/ + .LINK + https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/ + #> + [CmdletBinding()] + [OutputType([object])] + param( + # The API path beginning with '/wiki/', e.g. '/wiki/api/v2/pages'. + # A full absolute URL is also accepted (used when following pagination links). + [Parameter(Mandatory)] + [string]$ApiEndpoint, + + # The HTTP method. Defaults to GET. + [ValidateSet('GET', 'POST', 'PUT', 'DELETE')] + [string]$Method = 'GET', + + # The request body as a hashtable/object (serialized to JSON) or a pre-serialized JSON string. + [object]$Body, + + # A multipart/form-data payload (used for attachment uploads). + [hashtable]$Form, + + # Query-string parameters as a hashtable. + [hashtable]$Query, + + # Follow pagination and return every item from the 'results' collections. + [switch]$All, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($All -and $Method -ne 'GET') { + throw "The -All switch follows pagination and is only valid for GET requests, not $Method." + } + + $resolved = Resolve-ConfluenceContext -Context $Context + $token = Resolve-ConfluenceToken -Token $resolved.Token + $pair = '{0}:{1}' -f $resolved.Username, $token + $authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($pair)) + + # For v2 collection endpoints, default the page size from the PerPage config so + # -All pages in configured-sized batches instead of Confluence's small server + # default (~25), unless the caller passed an explicit limit. Absolute _links.next + # URLs and v1 endpoints are left untouched. + $effectiveQuery = if ($Query) { @{} + $Query } else { @{} } + if ($All -and $ApiEndpoint -like '/wiki/api/v2/*' -and -not ($effectiveQuery.Keys | Where-Object { $_ -ieq 'limit' })) { + Initialize-ConfluenceConfig + $perPage = $script:Confluence.Config['PerPage'] + if ($perPage) { + $effectiveQuery['limit'] = $perPage + } + } + + $endpoint = $ApiEndpoint + if ($effectiveQuery.Count -gt 0) { + $pairs = foreach ($entry in $effectiveQuery.GetEnumerator()) { + '{0}={1}' -f [uri]::EscapeDataString([string]$entry.Key), [uri]::EscapeDataString([string]$entry.Value) + } + $separator = if ($endpoint.Contains('?')) { '&' } else { '?' } + $endpoint = '{0}{1}{2}' -f $endpoint, $separator, ($pairs -join '&') + } + + $paginate = $All.IsPresent + $debug = $DebugPreference -eq 'Continue' + + do { + $uri = if ($endpoint -match '^https?://') { $endpoint } else { '{0}{1}' -f $resolved.ApiBaseUri, $endpoint } + + $headers = @{ + Authorization = $authorization + Accept = 'application/json' + } + + $request = @{ + Uri = $uri + Method = $Method + Headers = $headers + SkipHttpErrorCheck = $true + StatusCodeVariable = 'statusCode' + ResponseHeadersVariable = 'responseHeaders' + } + + if ($Form) { + $headers['X-Atlassian-Token'] = 'no-check' + $request['Form'] = $Form + } elseif ($null -ne $Body) { + $request['ContentType'] = 'application/json' + # Serialize with -InputObject rather than the pipeline: piping a + # single-element array to ConvertTo-Json emits a bare object and loses + # the array, whereas -InputObject serializes arrays and objects faithfully. + $request['Body'] = if ($Body -is [string]) { $Body } else { ConvertTo-Json -InputObject $Body -Depth 20 -Compress } + } + + if ($debug) { + Write-Debug "Request: $Method $uri" + foreach ($headerName in ($headers.Keys | Sort-Object)) { + $headerValue = if ($headerName -eq 'Authorization') { 'Basic ' } else { $headers[$headerName] } + Write-Debug ('Request header {0}: {1}' -f $headerName, $headerValue) + } + if ($request.ContainsKey('Body')) { + Write-Debug "Request body: $($request['Body'])" + } + } + + # -Debug:$false suppresses Invoke-RestMethod's built-in debug, which would + # otherwise dump the raw Authorization header (the Basic credential). The + # redacted request/response debug below is emitted under our control instead. + $response = Invoke-RestMethod @request -Debug:$false + + if ($debug) { + Write-Debug "Response status: $statusCode" + foreach ($headerName in ($responseHeaders.Keys | Sort-Object)) { + Write-Debug ('Response header {0}: {1}' -f $headerName, ($responseHeaders[$headerName] -join '; ')) + } + $responseText = if ($null -eq $response) { '' } else { $response | ConvertTo-Json -Depth 20 } + foreach ($line in ($responseText -split '\r?\n')) { + Write-Debug "Response body: $line" + } + } + + if ($statusCode -ge 400) { + $detail = if ($null -eq $response) { + '' + } elseif ($response.PSObject.Properties.Name -contains 'message' -and $response.message) { + $response.message + } elseif ($response.PSObject.Properties.Name -contains 'errors' -and $response.errors) { + ($response.errors | ForEach-Object { $_.title ?? $_.detail } | Where-Object { $_ }) -join '; ' + } else { + ($response | ConvertTo-Json -Depth 5 -Compress) + } + $message = "Confluence API $Method $uri failed with HTTP $statusCode. $detail".Trim() + $category = $responseHeaders['X-Failure-Category'] | Select-Object -First 1 + if ($category) { + $message = '{0} (failure category: {1})' -f $message, $category + } + throw $message + } + + if (-not $paginate -or $null -eq $response -or $response.PSObject.Properties.Name -notcontains 'results') { + return $response + } + + foreach ($item in $response.results) { + $item + } + $next = $response._links.next + $endpoint = if ([string]::IsNullOrEmpty($next)) { $null } else { [string]$next } + } while ($endpoint) +} diff --git a/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 b/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 new file mode 100644 index 0000000..a38ce88 --- /dev/null +++ b/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 @@ -0,0 +1,39 @@ +function Add-ConfluenceAttachment { + <# + .SYNOPSIS + Upload an attachment to a page (read:content-details and write:attachment). + .DESCRIPTION + Uploads a file as a page attachment. Confluence exposes attachment + creation only on the v1 content endpoint + (/wiki/rest/api/content/{id}/child/attachment), which requires BOTH + read:content-details and write:attachment. A token with write:attachment + but without read:content-details is rejected with a scope-check 401. + .EXAMPLE + Add-ConfluenceAttachment -PageId '12345' -Path ./diagram.png + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content---attachments/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The page (content) id to attach the file to. + [Parameter(Mandatory)] + [string]$PageId, + + # The path to the file to upload. + [Parameter(Mandatory)] + [string]$Path, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Attachment path is not a file: $Path" + } + $file = Get-Item -LiteralPath $Path + $endpoint = "/wiki/rest/api/content/$PageId/child/attachment" + + if ($PSCmdlet.ShouldProcess($PageId, "Upload attachment: $($file.Name)")) { + Invoke-ConfluenceRestMethod -ApiEndpoint $endpoint -Method 'PUT' -Form @{ file = $file } -Context $Context + } +} diff --git a/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 b/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 new file mode 100644 index 0000000..84153e4 --- /dev/null +++ b/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 @@ -0,0 +1,31 @@ +function Get-ConfluenceAttachment { + <# + .SYNOPSIS + Get page attachments (read:attachment). + .DESCRIPTION + Lists the attachments on a page, or returns a single attachment by id. + .EXAMPLE + Get-ConfluenceAttachment -PageId '12345' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-attachment/ + #> + [CmdletBinding(DefaultParameterSetName = 'ByPage')] + param( + # The page id whose attachments are listed. + [Parameter(Mandatory, ParameterSetName = 'ByPage')] + [string]$PageId, + + # A single attachment id to return. + [Parameter(Mandatory, ParameterSetName = 'ById')] + [string]$AttachmentId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($PSCmdlet.ParameterSetName -eq 'ById') { + return Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/attachments/$AttachmentId" -Context $Context + } + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/attachments" -All -Context $Context +} diff --git a/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 b/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 new file mode 100644 index 0000000..3ada77b --- /dev/null +++ b/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 @@ -0,0 +1,25 @@ +function Remove-ConfluenceAttachment { + <# + .SYNOPSIS + Delete an attachment (delete:attachment). + .DESCRIPTION + Deletes an attachment by id. + .EXAMPLE + Remove-ConfluenceAttachment -AttachmentId 'att12345' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-attachment/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The attachment id. + [Parameter(Mandatory)] + [string]$AttachmentId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($PSCmdlet.ShouldProcess($AttachmentId, 'Delete attachment')) { + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/attachments/$AttachmentId" -Method 'DELETE' -Context $Context + } +} diff --git a/src/functions/public/Auth/Connect-Confluence.ps1 b/src/functions/public/Auth/Connect-Confluence.ps1 new file mode 100644 index 0000000..783e73b --- /dev/null +++ b/src/functions/public/Auth/Connect-Confluence.ps1 @@ -0,0 +1,92 @@ +function Connect-Confluence { + <# + .SYNOPSIS + Connect to Confluence and store a credential profile in the context vault. + .DESCRIPTION + Validates the supplied credentials with a lightweight authenticated call, + stores them as a named context (the token is kept as a SecureString), and + records the context as the module default. A token that authenticates but + lacks the read:space scope still connects (with a warning). + .EXAMPLE + $token = Read-Host -AsSecureString + Connect-Confluence -ApiBaseUri $uri -Username $user -Token $token -SpaceKey 'DOCS' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/intro/#auth + .LINK + https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/ + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([pscustomobject])] + param( + # The Confluence API-gateway base URI, e.g. 'https://api.atlassian.com/ex/confluence/'. + [Parameter(Mandatory)] + [string]$ApiBaseUri, + + # The service-account user (email) used for HTTP Basic authentication. + [Parameter(Mandatory)] + [string]$Username, + + # The scoped Atlassian API token as a SecureString. + [Parameter(Mandatory)] + [securestring]$Token, + + # The context name to store the profile under. Defaults to '//'. + [string]$Name, + + # An optional default space key stored with the profile. + [string]$SpaceKey, + + # Return the stored context. + [switch]$PassThru + ) + + if ([string]::IsNullOrEmpty($Name)) { + # api.atlassian.com is shared by every Confluence Cloud site, so the host + # alone is not unique. Include the trailing base-URI path segment (the + # cloudId for the API gateway) so each site gets a distinct default name. + $baseUri = [uri]$ApiBaseUri + $siteId = @($baseUri.AbsolutePath.Trim('/') -split '/') | Where-Object { $_ } | Select-Object -Last 1 + $Name = if ($siteId) { + '{0}/{1}/{2}' -f $baseUri.Host, $siteId, $Username + } else { + '{0}/{1}' -f $baseUri.Host, $Username + } + } + + if ($Name -eq $script:Confluence.DefaultConfig.ID) { + throw "The context name '$Name' is reserved for the module configuration; choose a different -Name." + } + + $context = @{ + ID = $Name + Name = $Name + Type = 'Confluence' + ApiBaseUri = $ApiBaseUri.TrimEnd('/') + Username = $Username + Token = $Token + SpaceKey = $SpaceKey + } + + if (-not $PSCmdlet.ShouldProcess($Name, 'Connect to Confluence and store credential context')) { + return + } + + # Validate the credentials before persisting them. A scope-check rejection + # still proves the token authenticated (it merely lacks read:space); only a + # genuine authentication failure is fatal. + try { + $null = Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/spaces' -Query @{ limit = 1 } -Context $context + } catch { + if ($_.Exception.Message -notmatch 'FAILURE_CLIENT_SCOPE_CHECK|scope does not match') { + throw + } + Write-Warning "Token authenticated but lacks read:space; space listing is unavailable for context '$Name'." + } + + $stored = Set-Context -ID $Name -Context $context -Vault $script:Confluence.ContextVault -PassThru + Set-ConfluenceConfig -Name 'DefaultContext' -Value $Name + + if ($PassThru) { + $stored + } +} diff --git a/src/functions/public/Auth/Disconnect-Confluence.ps1 b/src/functions/public/Auth/Disconnect-Confluence.ps1 new file mode 100644 index 0000000..7f44c62 --- /dev/null +++ b/src/functions/public/Auth/Disconnect-Confluence.ps1 @@ -0,0 +1,31 @@ +function Disconnect-Confluence { + <# + .SYNOPSIS + Remove a stored Confluence credential profile. + .DESCRIPTION + Deletes the named context from the vault and clears it as the default + context if it was the current default. + .EXAMPLE + Disconnect-Confluence -Name 'sandbox' + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The context name to remove. Defaults to the current default context. + [string]$Name + ) + + Initialize-ConfluenceConfig + if ([string]::IsNullOrEmpty($Name)) { + $Name = $script:Confluence.Config['DefaultContext'] + } + if ([string]::IsNullOrEmpty($Name)) { + return + } + + if ($PSCmdlet.ShouldProcess($Name, 'Remove Confluence credential context')) { + Remove-Context -ID $Name -Vault $script:Confluence.ContextVault -ErrorAction SilentlyContinue + if ($script:Confluence.Config['DefaultContext'] -eq $Name) { + Set-ConfluenceConfig -Name 'DefaultContext' -Value '' + } + } +} diff --git a/src/functions/public/Auth/Get-ConfluenceContext.ps1 b/src/functions/public/Auth/Get-ConfluenceContext.ps1 new file mode 100644 index 0000000..8e62a6f --- /dev/null +++ b/src/functions/public/Auth/Get-ConfluenceContext.ps1 @@ -0,0 +1,35 @@ +function Get-ConfluenceContext { + <# + .SYNOPSIS + Get a stored Confluence credential profile. + .DESCRIPTION + Returns the named context, the default context, or (with -ListAvailable) + all stored Confluence contexts. The token remains a SecureString. + .EXAMPLE + Get-ConfluenceContext + #> + [CmdletBinding(DefaultParameterSetName = 'Single')] + [OutputType([pscustomobject])] + param( + # The context name to return. Defaults to the current default context. + [Parameter(ParameterSetName = 'Single')] + [string]$Name, + + # Return every Confluence context stored in the vault. + [Parameter(ParameterSetName = 'List')] + [switch]$ListAvailable + ) + + if ($ListAvailable) { + return Get-Context -Vault $script:Confluence.ContextVault | Where-Object { $_.ID -ne $script:Confluence.DefaultConfig.ID } + } + + Initialize-ConfluenceConfig + if ([string]::IsNullOrEmpty($Name)) { + $Name = $script:Confluence.Config['DefaultContext'] + } + if ([string]::IsNullOrEmpty($Name)) { + throw 'No context name specified and no default context is set.' + } + Get-Context -ID $Name -Vault $script:Confluence.ContextVault +} diff --git a/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 b/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 new file mode 100644 index 0000000..5c1d7ee --- /dev/null +++ b/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 @@ -0,0 +1,34 @@ +function Get-ConfluenceBlogPost { + <# + .SYNOPSIS + Get blog posts (read:page). + .DESCRIPTION + Returns a single blog post by id, the blog posts in a space, or all blog + posts across the site, following pagination. + .EXAMPLE + Get-ConfluenceBlogPost -SpaceId '123456' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-blog-post/ + #> + [CmdletBinding(DefaultParameterSetName = 'List')] + param( + # A single blog post id to return. + [Parameter(Mandatory, ParameterSetName = 'ById')] + [string]$BlogPostId, + + # List blog posts in this space id. Omit to list across the whole site. + [Parameter(ParameterSetName = 'List')] + [string]$SpaceId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($PSCmdlet.ParameterSetName -eq 'ById') { + return Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/blogposts/$BlogPostId" -Context $Context + } + if (-not [string]::IsNullOrEmpty($SpaceId)) { + return Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/spaces/$SpaceId/blogposts" -All -Context $Context + } + Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/blogposts' -All -Context $Context +} diff --git a/src/functions/public/Comments/Add-ConfluenceComment.ps1 b/src/functions/public/Comments/Add-ConfluenceComment.ps1 new file mode 100644 index 0000000..dda55f6 --- /dev/null +++ b/src/functions/public/Comments/Add-ConfluenceComment.ps1 @@ -0,0 +1,41 @@ +function Add-ConfluenceComment { + <# + .SYNOPSIS + Add a footer comment to a page (write:comment). + .DESCRIPTION + Creates a footer comment on the given page. + .EXAMPLE + Add-ConfluenceComment -PageId '12345' -Body '

Nice page.

' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-comment/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The page id to comment on. + [Parameter(Mandatory)] + [string]$PageId, + + # The comment body. + [Parameter(Mandatory)] + [string]$Body, + + # The body representation. Defaults to 'storage'. + [ValidateSet('storage', 'atlas_doc_format', 'wiki')] + [string]$Representation = 'storage', + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $payload = @{ + pageId = $PageId + body = @{ + representation = $Representation + value = $Body + } + } + + if ($PSCmdlet.ShouldProcess($PageId, 'Add footer comment')) { + Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/footer-comments' -Method 'POST' -Body $payload -Context $Context + } +} diff --git a/src/functions/public/Comments/Get-ConfluenceComment.ps1 b/src/functions/public/Comments/Get-ConfluenceComment.ps1 new file mode 100644 index 0000000..ed0a199 --- /dev/null +++ b/src/functions/public/Comments/Get-ConfluenceComment.ps1 @@ -0,0 +1,23 @@ +function Get-ConfluenceComment { + <# + .SYNOPSIS + List the footer comments on a page (read:comment). + .DESCRIPTION + Returns every footer comment on the page, following pagination. + .EXAMPLE + Get-ConfluenceComment -PageId '12345' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-comment/ + #> + [CmdletBinding()] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/footer-comments" -All -Context $Context +} diff --git a/src/functions/public/Comments/Remove-ConfluenceComment.ps1 b/src/functions/public/Comments/Remove-ConfluenceComment.ps1 new file mode 100644 index 0000000..69f154b --- /dev/null +++ b/src/functions/public/Comments/Remove-ConfluenceComment.ps1 @@ -0,0 +1,25 @@ +function Remove-ConfluenceComment { + <# + .SYNOPSIS + Delete a footer comment (delete:comment). + .DESCRIPTION + Permanently deletes a footer comment by id. + .EXAMPLE + Remove-ConfluenceComment -CommentId '55555' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-comment/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The footer comment id. + [Parameter(Mandatory)] + [string]$CommentId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($PSCmdlet.ShouldProcess($CommentId, 'Delete footer comment')) { + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/footer-comments/$CommentId" -Method 'DELETE' -Context $Context + } +} diff --git a/src/functions/public/Config/Get-ConfluenceConfig.ps1 b/src/functions/public/Config/Get-ConfluenceConfig.ps1 new file mode 100644 index 0000000..3dd4299 --- /dev/null +++ b/src/functions/public/Config/Get-ConfluenceConfig.ps1 @@ -0,0 +1,22 @@ +function Get-ConfluenceConfig { + <# + .SYNOPSIS + Get the Confluence module configuration. + .DESCRIPTION + Returns the whole configuration hashtable, or the value of a single + named configuration item. + .EXAMPLE + Get-ConfluenceConfig -Name 'DefaultContext' + #> + [CmdletBinding()] + param( + # The configuration item to return. If omitted, the whole config is returned. + [string]$Name + ) + + Initialize-ConfluenceConfig + if ([string]::IsNullOrEmpty($Name)) { + return $script:Confluence.Config + } + $script:Confluence.Config[$Name] +} diff --git a/src/functions/public/Config/Set-ConfluenceConfig.ps1 b/src/functions/public/Config/Set-ConfluenceConfig.ps1 new file mode 100644 index 0000000..63f12d3 --- /dev/null +++ b/src/functions/public/Config/Set-ConfluenceConfig.ps1 @@ -0,0 +1,28 @@ +function Set-ConfluenceConfig { + <# + .SYNOPSIS + Set a Confluence module configuration value. + .DESCRIPTION + Updates a single configuration item and persists the configuration to + the context vault. + .EXAMPLE + Set-ConfluenceConfig -Name 'PerPage' -Value 50 + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The configuration item name. + [Parameter(Mandatory)] + [string]$Name, + + # The value to store. + [Parameter(Mandatory)] + [AllowEmptyString()] + [object]$Value + ) + + Initialize-ConfluenceConfig + if ($PSCmdlet.ShouldProcess("Confluence config '$Name'", 'Set')) { + $script:Confluence.Config[$Name] = $Value + $null = Set-Context -ID $script:Confluence.DefaultConfig.ID -Context $script:Confluence.Config -Vault $script:Confluence.ContextVault + } +} diff --git a/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 b/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 new file mode 100644 index 0000000..0c5b983 --- /dev/null +++ b/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 @@ -0,0 +1,33 @@ +function Get-ConfluenceContentProperty { + <# + .SYNOPSIS + Get content properties of a page (read:page). + .DESCRIPTION + Returns all content properties on a page, or the single property with the + given key. In v2, a page's content properties are governed by the page's + own scope (read:page); the read:content.property scope applies to the v1 + property API. + .EXAMPLE + Get-ConfluenceContentProperty -PageId '12345' -Key 'my-prop' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-content-properties/ + #> + [CmdletBinding()] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The property key to return. If omitted, all properties are returned. + [string]$Key, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $all = Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/properties" -All -Context $Context + if ([string]::IsNullOrEmpty($Key)) { + return $all + } + $all | Where-Object { $_.key -eq $Key } | Select-Object -First 1 +} diff --git a/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 b/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 new file mode 100644 index 0000000..8c9e8e8 --- /dev/null +++ b/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 @@ -0,0 +1,30 @@ +function Remove-ConfluenceContentProperty { + <# + .SYNOPSIS + Delete a content property from a page (read:page and write:page). + .DESCRIPTION + Deletes a content property by its id. In v2, page content properties are + governed by the page's own scope (read:page and write:page). + .EXAMPLE + Remove-ConfluenceContentProperty -PageId '12345' -PropertyId '98765' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-content-properties/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The content-property id. + [Parameter(Mandatory)] + [string]$PropertyId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($PSCmdlet.ShouldProcess("$PageId/$PropertyId", 'Delete content property')) { + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/properties/$PropertyId" -Method 'DELETE' -Context $Context + } +} diff --git a/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 b/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 new file mode 100644 index 0000000..d4e7dab --- /dev/null +++ b/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 @@ -0,0 +1,56 @@ +function Set-ConfluenceContentProperty { + <# + .SYNOPSIS + Create or update a content property on a page (read:page and write:page). + .DESCRIPTION + Creates the property if it does not exist, or updates it (incrementing + its version) if it does. In v2, page content properties are governed by + the page's own scope: reading requires read:page and writing requires + write:page. + .EXAMPLE + Set-ConfluenceContentProperty -PageId '12345' -Key 'owner' -Value @{ team = 'ai' } + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-content-properties/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The property key. + [Parameter(Mandatory)] + [string]$Key, + + # The property value (any JSON-serializable object). + [Parameter(Mandatory)] + [object]$Value, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $existing = Get-ConfluenceContentProperty -PageId $PageId -Key $Key -Context $Context + + if ($existing) { + $payload = @{ + key = $Key + value = $Value + version = @{ + number = [int]$existing.version.number + 1 + } + } + $endpoint = "/wiki/api/v2/pages/$PageId/properties/$($existing.id)" + if ($PSCmdlet.ShouldProcess("$PageId/$Key", 'Update content property')) { + Invoke-ConfluenceRestMethod -ApiEndpoint $endpoint -Method 'PUT' -Body $payload -Context $Context + } + } else { + $payload = @{ + key = $Key + value = $Value + } + if ($PSCmdlet.ShouldProcess("$PageId/$Key", 'Create content property')) { + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/properties" -Method 'POST' -Body $payload -Context $Context + } + } +} diff --git a/src/functions/public/Folders/Get-ConfluenceFolder.ps1 b/src/functions/public/Folders/Get-ConfluenceFolder.ps1 new file mode 100644 index 0000000..d21b42a --- /dev/null +++ b/src/functions/public/Folders/Get-ConfluenceFolder.ps1 @@ -0,0 +1,23 @@ +function Get-ConfluenceFolder { + <# + .SYNOPSIS + Get a Confluence folder by id (read:folder). + .DESCRIPTION + Returns a single folder. + .EXAMPLE + Get-ConfluenceFolder -FolderId '67890' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-folder/ + #> + [CmdletBinding()] + param( + # The folder id. + [Parameter(Mandatory)] + [string]$FolderId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/folders/$FolderId" -Context $Context +} diff --git a/src/functions/public/Folders/New-ConfluenceFolder.ps1 b/src/functions/public/Folders/New-ConfluenceFolder.ps1 new file mode 100644 index 0000000..19041a8 --- /dev/null +++ b/src/functions/public/Folders/New-ConfluenceFolder.ps1 @@ -0,0 +1,40 @@ +function New-ConfluenceFolder { + <# + .SYNOPSIS + Create a Confluence folder (write:folder). + .DESCRIPTION + Creates a folder in a space, optionally beneath a parent page or folder. + .EXAMPLE + New-ConfluenceFolder -SpaceId $spaceId -Title 'Archive' -ParentId $pageId + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-folder/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The id of the space to create the folder in. + [Parameter(Mandatory)] + [string]$SpaceId, + + # The folder title. + [Parameter(Mandatory)] + [string]$Title, + + # The id of the parent page or folder. Omit to create at the space root. + [string]$ParentId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $payload = @{ + spaceId = $SpaceId + title = $Title + } + if (-not [string]::IsNullOrEmpty($ParentId)) { + $payload['parentId'] = $ParentId + } + + if ($PSCmdlet.ShouldProcess($Title, 'Create Confluence folder')) { + Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/folders' -Method 'POST' -Body $payload -Context $Context + } +} diff --git a/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 b/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 new file mode 100644 index 0000000..f04c8dd --- /dev/null +++ b/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 @@ -0,0 +1,25 @@ +function Remove-ConfluenceFolder { + <# + .SYNOPSIS + Delete a Confluence folder (delete:folder). + .DESCRIPTION + Moves a folder to the trash. + .EXAMPLE + Remove-ConfluenceFolder -FolderId '67890' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-folder/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The folder id to delete. + [Parameter(Mandatory)] + [string]$FolderId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($PSCmdlet.ShouldProcess($FolderId, 'Delete Confluence folder')) { + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/folders/$FolderId" -Method 'DELETE' -Context $Context + } +} diff --git a/src/functions/public/Get-PSModuleTest.ps1 b/src/functions/public/Get-PSModuleTest.ps1 deleted file mode 100644 index ffe3483..0000000 --- a/src/functions/public/Get-PSModuleTest.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -#Requires -Modules Utilities - -function Get-PSModuleTest { - <# - .SYNOPSIS - Performs tests on a module. - - .DESCRIPTION - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - #> - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/functions/public/Labels/Add-ConfluenceLabel.ps1 b/src/functions/public/Labels/Add-ConfluenceLabel.ps1 new file mode 100644 index 0000000..d5b84d2 --- /dev/null +++ b/src/functions/public/Labels/Add-ConfluenceLabel.ps1 @@ -0,0 +1,44 @@ +function Add-ConfluenceLabel { + <# + .SYNOPSIS + Add one or more labels to a page (read:label and write:label). + .DESCRIPTION + Adds labels to a page. Confluence exposes label creation only on the v1 + content endpoint (/wiki/rest/api/content/{id}/label), which the granular + read:label and write:label scopes authorise. + .EXAMPLE + Add-ConfluenceLabel -PageId '12345' -Label 'docs', 'published' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-labels/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The page (content) id to label. + [Parameter(Mandatory)] + [string]$PageId, + + # One or more label names to add. + [Parameter(Mandatory)] + [string[]]$Label, + + # The label prefix. Defaults to 'global'. + [string]$Prefix = 'global', + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $payload = foreach ($name in $Label) { + @{ + prefix = $Prefix + name = $name + } + } + # @(...) forces an array (a single label is otherwise a scalar) and -InputObject + # avoids the pipeline, so the v1 label endpoint always receives a JSON array. + $json = ConvertTo-Json -InputObject @($payload) -Depth 5 -Compress + + if ($PSCmdlet.ShouldProcess($PageId, "Add label(s): $($Label -join ', ')")) { + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/rest/api/content/$PageId/label" -Method 'POST' -Body $json -Context $Context + } +} diff --git a/src/functions/public/Labels/Get-ConfluenceLabel.ps1 b/src/functions/public/Labels/Get-ConfluenceLabel.ps1 new file mode 100644 index 0000000..fdb1b32 --- /dev/null +++ b/src/functions/public/Labels/Get-ConfluenceLabel.ps1 @@ -0,0 +1,25 @@ +function Get-ConfluenceLabel { + <# + .SYNOPSIS + List the labels on a page (read:page). + .DESCRIPTION + Returns every label on the page, following pagination. In v2 a page's + labels are read under the page's own scope (read:page); the read:label + scope covers only the site-wide GET /labels endpoint. + .EXAMPLE + Get-ConfluenceLabel -PageId '12345' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-label/ + #> + [CmdletBinding()] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/labels" -All -Context $Context +} diff --git a/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 b/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 new file mode 100644 index 0000000..619bb9b --- /dev/null +++ b/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 @@ -0,0 +1,29 @@ +function Remove-ConfluenceLabel { + <# + .SYNOPSIS + Remove a label from a page (write:label). + .DESCRIPTION + Removes a label from a page via the v1 content endpoint. + .EXAMPLE + Remove-ConfluenceLabel -PageId '12345' -Label 'docs' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-labels/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The page (content) id. + [Parameter(Mandatory)] + [string]$PageId, + + # The label name to remove. + [Parameter(Mandatory)] + [string]$Label, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($PSCmdlet.ShouldProcess($PageId, "Remove label: $Label")) { + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/rest/api/content/$PageId/label" -Method 'DELETE' -Query @{ name = $Label } -Context $Context + } +} diff --git a/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 b/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 new file mode 100644 index 0000000..ee495bb --- /dev/null +++ b/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 @@ -0,0 +1,24 @@ +function Get-ConfluenceDescendant { + <# + .SYNOPSIS + List all descendants of a page (read:hierarchical-content). + .DESCRIPTION + Returns every descendant (child, grandchild, ...) of a page, following + pagination automatically. + .EXAMPLE + Get-ConfluenceDescendant -PageId '12345' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-descendants/ + #> + [CmdletBinding()] + param( + # The ancestor page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/descendants" -All -Context $Context +} diff --git a/src/functions/public/Pages/Get-ConfluencePage.ps1 b/src/functions/public/Pages/Get-ConfluencePage.ps1 new file mode 100644 index 0000000..419abc0 --- /dev/null +++ b/src/functions/public/Pages/Get-ConfluencePage.ps1 @@ -0,0 +1,27 @@ +function Get-ConfluencePage { + <# + .SYNOPSIS + Get a Confluence page by id (read:page). + .DESCRIPTION + Returns a single page, including its body in the requested format. + .EXAMPLE + Get-ConfluencePage -PageId '12345' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/ + #> + [CmdletBinding()] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The body format to return. Defaults to 'storage'. + [ValidateSet('storage', 'atlas_doc_format', 'view', 'export_view', 'anonymous_export_view', 'styled_view', 'editor')] + [string]$BodyFormat = 'storage', + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId" -Query @{ 'body-format' = $BodyFormat } -Context $Context +} diff --git a/src/functions/public/Pages/Get-ConfluencePageChild.ps1 b/src/functions/public/Pages/Get-ConfluencePageChild.ps1 new file mode 100644 index 0000000..df8c8f9 --- /dev/null +++ b/src/functions/public/Pages/Get-ConfluencePageChild.ps1 @@ -0,0 +1,23 @@ +function Get-ConfluencePageChild { + <# + .SYNOPSIS + List the direct child pages of a page (read:page). + .DESCRIPTION + Returns every direct child page, following pagination automatically. + .EXAMPLE + Get-ConfluencePageChild -PageId '12345' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-children/ + #> + [CmdletBinding()] + param( + # The parent page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/children" -All -Context $Context +} diff --git a/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 b/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 new file mode 100644 index 0000000..13489f6 --- /dev/null +++ b/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 @@ -0,0 +1,23 @@ +function Get-ConfluencePageVersion { + <# + .SYNOPSIS + List the version history of a page (read:page). + .DESCRIPTION + Returns every version entry for a page, newest first, following pagination. + .EXAMPLE + Get-ConfluencePageVersion -PageId '12345' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-version/ + #> + [CmdletBinding()] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId/versions" -All -Context $Context +} diff --git a/src/functions/public/Pages/New-ConfluencePage.ps1 b/src/functions/public/Pages/New-ConfluencePage.ps1 new file mode 100644 index 0000000..402d0cc --- /dev/null +++ b/src/functions/public/Pages/New-ConfluencePage.ps1 @@ -0,0 +1,53 @@ +function New-ConfluencePage { + <# + .SYNOPSIS + Create a Confluence page (write:page). + .DESCRIPTION + Creates a page in a space, optionally beneath a parent page. A page with + no parent is created under the space home page. + .EXAMPLE + New-ConfluencePage -SpaceId $spaceId -Title 'Docs' -Body '

Hello

' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The id of the space to create the page in. + [Parameter(Mandatory)] + [string]$SpaceId, + + # The page title (must be unique within the space). + [Parameter(Mandatory)] + [string]$Title, + + # The id of the parent page. Omit to create a top-level page. + [string]$ParentId, + + # The page body in the chosen representation. Defaults to empty. + [string]$Body = '', + + # The body representation. Defaults to 'storage'. + [ValidateSet('storage', 'atlas_doc_format', 'wiki')] + [string]$Representation = 'storage', + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $payload = @{ + spaceId = $SpaceId + status = 'current' + title = $Title + body = @{ + representation = $Representation + value = $Body + } + } + if (-not [string]::IsNullOrEmpty($ParentId)) { + $payload['parentId'] = $ParentId + } + + if ($PSCmdlet.ShouldProcess($Title, 'Create Confluence page')) { + Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/pages' -Method 'POST' -Body $payload -Context $Context + } +} diff --git a/src/functions/public/Pages/Remove-ConfluencePage.ps1 b/src/functions/public/Pages/Remove-ConfluencePage.ps1 new file mode 100644 index 0000000..1a63e27 --- /dev/null +++ b/src/functions/public/Pages/Remove-ConfluencePage.ps1 @@ -0,0 +1,45 @@ +function Remove-ConfluencePage { + <# + .SYNOPSIS + Delete a Confluence page (delete:page). + .DESCRIPTION + Moves a page to the trash. With -Recurse, direct and nested child pages + are removed first so that a whole subtree can be deleted. With -Purge the + page is permanently deleted (only valid for already-trashed pages). + .EXAMPLE + Remove-ConfluencePage -PageId '12345' -Recurse + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The page id to delete. + [Parameter(Mandatory)] + [string]$PageId, + + # Remove child pages before removing the page itself. + [switch]$Recurse, + + # Permanently delete instead of moving to trash. + [switch]$Purge, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($Recurse) { + $children = Get-ConfluencePageChild -PageId $PageId -Context $Context + foreach ($child in $children) { + Remove-ConfluencePage -PageId $child.id -Recurse -Purge:$Purge -Context $Context + } + } + + $endpoint = "/wiki/api/v2/pages/$PageId" + if ($Purge) { + $endpoint = '{0}?purge=true' -f $endpoint + } + + if ($PSCmdlet.ShouldProcess($PageId, 'Delete Confluence page')) { + Invoke-ConfluenceRestMethod -ApiEndpoint $endpoint -Method 'DELETE' -Context $Context + } +} diff --git a/src/functions/public/Pages/Set-ConfluencePage.ps1 b/src/functions/public/Pages/Set-ConfluencePage.ps1 new file mode 100644 index 0000000..c7ab81a --- /dev/null +++ b/src/functions/public/Pages/Set-ConfluencePage.ps1 @@ -0,0 +1,59 @@ +function Set-ConfluencePage { + <# + .SYNOPSIS + Update a Confluence page (read:page and write:page). + .DESCRIPTION + Updates the title and/or body of a page, automatically incrementing the + version number. Unspecified fields keep their current values. The current + page is read first (read:page) to preserve unspecified fields and to + obtain the version number, then written back (write:page). + .EXAMPLE + Set-ConfluencePage -PageId '12345' -Body '

Updated

' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/ + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The page id to update. + [Parameter(Mandatory)] + [string]$PageId, + + # The new title. Defaults to the existing title. + [string]$Title, + + # The new body. Defaults to the existing body. + [string]$Body, + + # The body representation. Defaults to 'storage'. + [ValidateSet('storage', 'atlas_doc_format', 'wiki')] + [string]$Representation = 'storage', + + # The page status. Defaults to 'current'. + [string]$Status = 'current', + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $current = Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId" -Query @{ 'body-format' = $Representation } -Context $Context + + $newTitle = if ($PSBoundParameters.ContainsKey('Title')) { $Title } else { $current.title } + $newBody = if ($PSBoundParameters.ContainsKey('Body')) { $Body } else { $current.body.$Representation.value } + + $payload = @{ + id = $PageId + status = $Status + title = $newTitle + version = @{ + number = [int]$current.version.number + 1 + } + body = @{ + representation = $Representation + value = $newBody + } + } + + if ($PSCmdlet.ShouldProcess($PageId, 'Update Confluence page')) { + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/pages/$PageId" -Method 'PUT' -Body $payload -Context $Context + } +} diff --git a/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 b/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 new file mode 100644 index 0000000..da10bd8 --- /dev/null +++ b/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 @@ -0,0 +1,29 @@ +function Get-ConfluenceRestriction { + <# + .SYNOPSIS + Get the content restrictions on a page (read:content-details). + .DESCRIPTION + Returns the read/update restrictions applied to a page. Reading + restrictions requires read:content-details; the read:content.restriction + scope only covers the narrow byOperation/{op}/user|group status checks. + Content restrictions are part of the v1 content API (see the link). + .EXAMPLE + Get-ConfluenceRestriction -PageId '12345' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-restrictions/ + #> + [CmdletBinding()] + param( + # The page id. + [Parameter(Mandatory)] + [string]$PageId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + # Content restrictions live on the v1 content API; there is no equivalent v2 + # endpoint (a v2 '/pages/{id}/restrictions' path is rejected with a scope + # check). The v1 endpoint returns the read/update operations in 'results'. + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/rest/api/content/$PageId/restriction" -All -Context $Context +} diff --git a/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 b/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 new file mode 100644 index 0000000..57fa6fc --- /dev/null +++ b/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 @@ -0,0 +1,45 @@ +function Get-ConfluenceSiteInfo { + <# + .SYNOPSIS + Get basic information about the connected Confluence site (read:space or read:page). + .DESCRIPTION + Returns the site's cloud id (parsed from the API-gateway base URI) and + the browsable site (wiki) base URL (read from the API's _links.base). A + scoped v2 token cannot reach the dedicated site/settings endpoints (site + name, edition, build number) — those are v1-only and are rejected. + .EXAMPLE + Get-ConfluenceSiteInfo + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/ + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $resolved = Resolve-ConfluenceContext -Context $Context + $cloudId = if ($resolved.ApiBaseUri -match '/ex/confluence/([0-9a-fA-F-]+)') { $Matches[1] } else { $null } + + # _links.base (the browsable site URL) appears on any v2 collection response; + # try a couple so this works whether the token has read:space or only read:page. + $siteUrl = $null + foreach ($probe in '/wiki/api/v2/spaces', '/wiki/api/v2/pages') { + try { + $response = Invoke-ConfluenceRestMethod -ApiEndpoint $probe -Query @{ limit = 1 } -Context $Context + if ($response._links.base) { + $siteUrl = $response._links.base + break + } + } catch { + continue + } + } + + [pscustomobject]@{ + CloudId = $cloudId + ApiBaseUri = $resolved.ApiBaseUri + SiteUrl = $siteUrl + } +} diff --git a/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 b/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 new file mode 100644 index 0000000..7d465d0 --- /dev/null +++ b/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 @@ -0,0 +1,41 @@ +function Get-ConfluenceSpace { + <# + .SYNOPSIS + Get a Confluence space (read:space). + .DESCRIPTION + Returns a space by key or by id. When neither is supplied the default + space key from the connected context is used. + .EXAMPLE + Get-ConfluenceSpace -Key 'DOCS' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/ + #> + [CmdletBinding(DefaultParameterSetName = 'ByKey')] + param( + # The space key, e.g. 'DOCS'. + [Parameter(ParameterSetName = 'ByKey')] + [string]$Key, + + # The space id. + [Parameter(Mandatory, ParameterSetName = 'ById')] + [string]$Id, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + if ($PSCmdlet.ParameterSetName -eq 'ById') { + return Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/spaces/$Id" -Context $Context + } + + if ([string]::IsNullOrEmpty($Key)) { + $resolved = Resolve-ConfluenceContext -Context $Context + $Key = $resolved.SpaceKey + } + if ([string]::IsNullOrEmpty($Key)) { + throw 'Specify -Key or -Id, or connect with a default SpaceKey.' + } + + $response = Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/spaces' -Query @{ keys = $Key } -Context $Context + $response.results | Where-Object { $_.key -eq $Key } | Select-Object -First 1 +} diff --git a/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 b/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 new file mode 100644 index 0000000..6b6a3fa --- /dev/null +++ b/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 @@ -0,0 +1,28 @@ +function Get-ConfluenceSpacePermission { + <# + .SYNOPSIS + List the permission assignments on a space (read:space). + .DESCRIPTION + Returns every permission assignment in the space — each entry pairs a + principal (user or group) with an operation (for example read/space, + create/page, delete/page, restrict_content/space). This is the space's + whole permission table, not a filtered "effective permissions for the + current user" view (Confluence exposes that only on the v1 operations + expansion, which a granular v2 token cannot call). + .EXAMPLE + Get-ConfluenceSpacePermission -SpaceId '123456' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space-permissions/ + #> + [CmdletBinding()] + param( + # The space id whose permission assignments are returned. + [Parameter(Mandatory)] + [string]$SpaceId, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/spaces/$SpaceId/permissions" -All -Context $Context +} diff --git a/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 b/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 new file mode 100644 index 0000000..bbb67a5 --- /dev/null +++ b/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 @@ -0,0 +1,30 @@ +function Get-ConfluenceSpaceProperty { + <# + .SYNOPSIS + Get space properties (read:space). + .DESCRIPTION + Returns all properties on a space, or the single property with the given key. + .EXAMPLE + Get-ConfluenceSpaceProperty -SpaceId '123456' + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space-properties/ + #> + [CmdletBinding()] + param( + # The space id. + [Parameter(Mandatory)] + [string]$SpaceId, + + # The property key to return. If omitted, all properties are returned. + [string]$Key, + + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + $all = Invoke-ConfluenceRestMethod -ApiEndpoint "/wiki/api/v2/spaces/$SpaceId/properties" -All -Context $Context + if ([string]::IsNullOrEmpty($Key)) { + return $all + } + $all | Where-Object { $_.key -eq $Key } | Select-Object -First 1 +} diff --git a/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 b/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 new file mode 100644 index 0000000..77c37bb --- /dev/null +++ b/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 @@ -0,0 +1,23 @@ +function Get-ConfluenceCurrentUser { + <# + .SYNOPSIS + Get the current (authenticated) user — Confluence's "me" endpoint (read:content-details). + .DESCRIPTION + Returns the account behind the connected token via the Confluence + current-user endpoint (v1 /wiki/rest/api/user/current), which requires + read:content-details. The read:user scope only covers the anonymous-user + and group-membership endpoints, so a token without read:content-details is + rejected with a scope-check 401. + .EXAMPLE + Get-ConfluenceCurrentUser + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-users/ + #> + [CmdletBinding()] + param( + # The context to use: an object, a context name, or $null for the default. + [object]$Context + ) + + Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/rest/api/user/current' -Context $Context +} diff --git a/src/header.ps1 b/src/header.ps1 index cc1fde9..5e46d8e 100644 --- a/src/header.ps1 +++ b/src/header.ps1 @@ -1,3 +1,44 @@ -[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', Justification = 'Contains long links.')] +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.6' } + +<# + Confluence — a small, generalized PowerShell client for the Atlassian + Confluence Cloud REST API v2. + + Design: + - Credential profiles and module configuration are persisted with the + PSModule 'Context' module (following the PSModule/GitHub pattern). + - Every resource function (pages, folders, comments, labels, content + properties, attachments, restrictions, spaces) is a thin wrapper over the + single generic 'Invoke-ConfluenceRestMethod' function. + - The service-account token is a scoped, granular (v2-aligned) Atlassian + token, so calls go to the API-gateway host on the '/wiki/api/v2' path. + A few v1 endpoints that Atlassian has mapped to granular scopes (labels, + restrictions, attachments, current user) are also reachable; most v1 + endpoints are not. + + Documentation style: parameter descriptions live inline as comments directly + above each parameter (docs close to code). Comment-based help blocks carry + only .SYNOPSIS / .DESCRIPTION / .EXAMPLE / .LINK; PowerShell sources the + per-parameter help from the inline comments. + + References (used to determine the OAuth 2.0 scope each function requires): + - REST API v2 reference: https://developer.atlassian.com/cloud/confluence/rest/v2/intro/ + - REST API v2 OpenAPI spec: https://dac-static.atlassian.com/cloud/confluence/openapi-v2.v3.json + - REST API v1 reference: https://developer.atlassian.com/cloud/confluence/rest/v1/intro/ + - REST API v1 (Swagger) spec: https://dac-static.atlassian.com/cloud/confluence/swagger.v3.json + - OAuth 2.0 scopes: https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/ + + Each public function's comment-based help names the granular scope(s) it needs + (in the .SYNOPSIS) and links the matching reference page (.LINK). Note that in + v2 many child resources are governed by the PARENT's scope: listing a page's + labels, versions, children, or content properties requires read:page (not + read:label / read:content.property), and space permissions/properties require + read:space (not read:space.permission). +#> + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', Justification = 'Contains long links.')] [CmdletBinding()] param() + +$ErrorActionPreference = 'Stop' diff --git a/src/init/initializer.ps1 b/src/init/initializer.ps1 deleted file mode 100644 index 28396fb..0000000 --- a/src/init/initializer.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -Write-Verbose '-------------------------------' -Write-Verbose '--- THIS IS AN INITIALIZER ---' -Write-Verbose '-------------------------------' diff --git a/src/manifest.psd1 b/src/manifest.psd1 new file mode 100644 index 0000000..9a9fa8d --- /dev/null +++ b/src/manifest.psd1 @@ -0,0 +1,14 @@ +@{ + # A placeholder version; the build framework overwrites this with the version + # derived from the release tag. Present so the manifest passes analysis. + ModuleVersion = '0.0.0' + PrivateData = @{ + PSData = @{ + Tags = @( + 'Confluence' + 'Atlassian' + 'PSModule' + ) + } + } +} diff --git a/src/scripts/loader.ps1 b/src/scripts/loader.ps1 deleted file mode 100644 index 973735a..0000000 --- a/src/scripts/loader.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -Write-Verbose '-------------------------' -Write-Verbose '--- THIS IS A LOADER ---' -Write-Verbose '-------------------------' diff --git a/src/variables/private/Confluence.ps1 b/src/variables/private/Confluence.ps1 new file mode 100644 index 0000000..73685d6 --- /dev/null +++ b/src/variables/private/Confluence.ps1 @@ -0,0 +1,9 @@ +$script:Confluence = [pscustomobject]@{ + ContextVault = 'PSModule.Confluence' + DefaultConfig = @{ + ID = 'Module' + DefaultContext = '' + PerPage = 100 + } + Config = $null +} diff --git a/tests/Confluence.Tests.ps1 b/tests/Confluence.Tests.ps1 new file mode 100644 index 0000000..aafd65e --- /dev/null +++ b/tests/Confluence.Tests.ps1 @@ -0,0 +1,88 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Pester test cases assign variables that are used in other scopes.')] +[CmdletBinding()] +param() + +Describe 'Confluence' { + Context 'Module surface' { + BeforeAll { + $module = Get-Module -Name 'Confluence' + $commands = (Get-Command -Module 'Confluence').Name + } + + It 'is imported' { + $module | Should -Not -BeNullOrEmpty + } + + It 'declares a dependency on the Context module' { + $module.RequiredModules.Name | Should -Contain 'Context' + } + + It 'exports the connection and configuration commands' { + foreach ($name in @( + 'Connect-Confluence' + 'Disconnect-Confluence' + 'Get-ConfluenceContext' + 'Get-ConfluenceConfig' + 'Set-ConfluenceConfig' + )) { + $commands | Should -Contain $name + } + } + + It 'exports the core REST and resource commands' { + foreach ($name in @( + 'Invoke-ConfluenceRestMethod' + 'Get-ConfluenceSpace' + 'Get-ConfluenceSiteInfo' + 'New-ConfluencePage' + 'Get-ConfluencePage' + 'Set-ConfluencePage' + 'Remove-ConfluencePage' + 'Get-ConfluenceBlogPost' + 'New-ConfluenceFolder' + 'Add-ConfluenceComment' + 'Add-ConfluenceLabel' + 'Get-ConfluenceContentProperty' + 'Get-ConfluenceAttachment' + 'Get-ConfluenceRestriction' + 'Get-ConfluenceCurrentUser' + )) { + $commands | Should -Contain $name + } + } + + It 'does not export the private helper functions' { + foreach ($name in @( + 'Resolve-ConfluenceContext' + 'Resolve-ConfluenceToken' + 'Initialize-ConfluenceConfig' + 'ConvertTo-ConfluenceHashtable' + )) { + $commands | Should -Not -Contain $name + } + } + } + + # Integration tests run only when live credentials are provided through the + # repository's GitHub Environment secrets. They are skipped locally and on + # pull requests where the secrets are not available. + Context 'Integration' -Skip:([string]::IsNullOrEmpty($env:CONFLUENCE_API_TOKEN)) { + BeforeAll { + $secureToken = ConvertTo-SecureString -String $env:CONFLUENCE_API_TOKEN -AsPlainText -Force + Connect-Confluence -ApiBaseUri $env:CONFLUENCE_API_BASE_URI -Username $env:CONFLUENCE_USERNAME -Token $secureToken -SpaceKey $env:CONFLUENCE_SPACE_KEY -Name 'ci' + } + + AfterAll { + Disconnect-Confluence -Name 'ci' -ErrorAction SilentlyContinue + } + + It 'resolves the current user' { + Get-ConfluenceCurrentUser -Context 'ci' | Should -Not -BeNullOrEmpty + } + + It 'resolves the configured space' { + (Get-ConfluenceSpace -Key $env:CONFLUENCE_SPACE_KEY -Context 'ci').key | Should -Be $env:CONFLUENCE_SPACE_KEY + } + } +} diff --git a/tests/PSModuleTest.Tests.ps1 b/tests/PSModuleTest.Tests.ps1 deleted file mode 100644 index 933b85b..0000000 --- a/tests/PSModuleTest.Tests.ps1 +++ /dev/null @@ -1,5 +0,0 @@ -Describe 'Module' { - It 'Function: Get-PSModuleTest' { - Get-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' - } -} From 9be648138dd9bcf39986e92304b066bd61b49eb5 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Tue, 7 Jul 2026 22:46:07 +0200 Subject: [PATCH 02/21] =?UTF-8?q?=F0=9F=AA=B2=20[Fix]:=20Resolve=20PSScrip?= =?UTF-8?q?tAnalyzer=20findings=20in=20examples=20and=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the unused child-page variable in the Pages example - Splat Connect-Confluence in the integration test to stay within the line-length limit - Suppress PSAvoidUsingConvertToSecureStringWithPlainText (the API token is a CI environment secret) --- examples/Pages.ps1 | 2 +- tests/Confluence.Tests.ps1 | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/examples/Pages.ps1 b/examples/Pages.ps1 index 5dd0849..e83ba77 100644 --- a/examples/Pages.ps1 +++ b/examples/Pages.ps1 @@ -22,7 +22,7 @@ Get-ConfluencePage -PageId $page.id Set-ConfluencePage -PageId $page.id -Body '

Updated content

' # Add a child page, then list children and descendants -$child = New-ConfluencePage -SpaceId $space.id -Title 'Details' -ParentId $page.id -Body '

Nested

' +New-ConfluencePage -SpaceId $space.id -Title 'Details' -ParentId $page.id -Body '

Nested

' Get-ConfluencePageChild -PageId $page.id Get-ConfluenceDescendant -PageId $page.id diff --git a/tests/Confluence.Tests.ps1 b/tests/Confluence.Tests.ps1 index aafd65e..b415499 100644 --- a/tests/Confluence.Tests.ps1 +++ b/tests/Confluence.Tests.ps1 @@ -1,5 +1,7 @@ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester test cases assign variables that are used in other scopes.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'The API token is supplied as a CI environment secret and must be converted to a SecureString for Connect-Confluence.')] [CmdletBinding()] param() @@ -70,7 +72,14 @@ Describe 'Confluence' { Context 'Integration' -Skip:([string]::IsNullOrEmpty($env:CONFLUENCE_API_TOKEN)) { BeforeAll { $secureToken = ConvertTo-SecureString -String $env:CONFLUENCE_API_TOKEN -AsPlainText -Force - Connect-Confluence -ApiBaseUri $env:CONFLUENCE_API_BASE_URI -Username $env:CONFLUENCE_USERNAME -Token $secureToken -SpaceKey $env:CONFLUENCE_SPACE_KEY -Name 'ci' + $connectParams = @{ + ApiBaseUri = $env:CONFLUENCE_API_BASE_URI + Username = $env:CONFLUENCE_USERNAME + Token = $secureToken + SpaceKey = $env:CONFLUENCE_SPACE_KEY + Name = 'ci' + } + Connect-Confluence @connectParams } AfterAll { From fa9a9fa9b33231ea911199d80ea64bba28d5fb49 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Tue, 7 Jul 2026 23:10:24 +0200 Subject: [PATCH 03/21] Remove header.ps1 and move the module requirements to the REST entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oversized module design comment lived only in header.ps1. Drop the file and relocate the two #Requires statements (PowerShell 7.0 and Context 8.1.6) to the top of Invoke-ConfluenceRestMethod.ps1 — the core function that every command routes through — so the build still generates the manifest's RequiredModules and PowerShellVersion. The framework injects the default CmdletBinding/param header when header.ps1 is absent. --- .../API/Invoke-ConfluenceRestMethod.ps1 | 5 ++- src/header.ps1 | 44 ------------------- 2 files changed, 4 insertions(+), 45 deletions(-) delete mode 100644 src/header.ps1 diff --git a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 index 2e5d6ab..b8c79d7 100644 --- a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 +++ b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 @@ -1,4 +1,7 @@ -function Invoke-ConfluenceRestMethod { +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.6' } + +function Invoke-ConfluenceRestMethod { <# .SYNOPSIS Call the Confluence REST API using a stored (or supplied) context. diff --git a/src/header.ps1 b/src/header.ps1 deleted file mode 100644 index 5e46d8e..0000000 --- a/src/header.ps1 +++ /dev/null @@ -1,44 +0,0 @@ -#Requires -Version 7.0 -#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.6' } - -<# - Confluence — a small, generalized PowerShell client for the Atlassian - Confluence Cloud REST API v2. - - Design: - - Credential profiles and module configuration are persisted with the - PSModule 'Context' module (following the PSModule/GitHub pattern). - - Every resource function (pages, folders, comments, labels, content - properties, attachments, restrictions, spaces) is a thin wrapper over the - single generic 'Invoke-ConfluenceRestMethod' function. - - The service-account token is a scoped, granular (v2-aligned) Atlassian - token, so calls go to the API-gateway host on the '/wiki/api/v2' path. - A few v1 endpoints that Atlassian has mapped to granular scopes (labels, - restrictions, attachments, current user) are also reachable; most v1 - endpoints are not. - - Documentation style: parameter descriptions live inline as comments directly - above each parameter (docs close to code). Comment-based help blocks carry - only .SYNOPSIS / .DESCRIPTION / .EXAMPLE / .LINK; PowerShell sources the - per-parameter help from the inline comments. - - References (used to determine the OAuth 2.0 scope each function requires): - - REST API v2 reference: https://developer.atlassian.com/cloud/confluence/rest/v2/intro/ - - REST API v2 OpenAPI spec: https://dac-static.atlassian.com/cloud/confluence/openapi-v2.v3.json - - REST API v1 reference: https://developer.atlassian.com/cloud/confluence/rest/v1/intro/ - - REST API v1 (Swagger) spec: https://dac-static.atlassian.com/cloud/confluence/swagger.v3.json - - OAuth 2.0 scopes: https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/ - - Each public function's comment-based help names the granular scope(s) it needs - (in the .SYNOPSIS) and links the matching reference page (.LINK). Note that in - v2 many child resources are governed by the PARENT's scope: listing a page's - labels, versions, children, or content properties requires read:page (not - read:label / read:content.property), and space permissions/properties require - read:space (not read:space.permission). -#> - -[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', Justification = 'Contains long links.')] -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' From 04ae285326e669f2ea840a49259b083fdfb90bb6 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Tue, 7 Jul 2026 23:10:39 +0200 Subject: [PATCH 04/21] Remove the source manifest; tags come from repository topics The build generates the full manifest, and when the source manifest has no tags it collects them from the repository topics (Build-PSModuleManifest reads repositoryTopics). Repo topics set: confluence, atlassian, psmodule, powershell, powershell-module, pwsh, rest, rest-api. A missing source manifest is fully supported (the build starts from an empty manifest and derives version, description, project/license/icon URIs, and exports). --- src/manifest.psd1 | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 src/manifest.psd1 diff --git a/src/manifest.psd1 b/src/manifest.psd1 deleted file mode 100644 index 9a9fa8d..0000000 --- a/src/manifest.psd1 +++ /dev/null @@ -1,14 +0,0 @@ -@{ - # A placeholder version; the build framework overwrites this with the version - # derived from the release tag. Present so the manifest passes analysis. - ModuleVersion = '0.0.0' - PrivateData = @{ - PSData = @{ - Tags = @( - 'Confluence' - 'Atlassian' - 'PSModule' - ) - } - } -} From fdedf80df08ba59f894fcc08e32d1773efd31faa Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 00:42:29 +0200 Subject: [PATCH 05/21] Match the GitHub module comment-based help style and link each command to its docs page Indent the help sections and add blank lines between them, wrap examples in fenced powershell blocks with a short description, and make the first .LINK each public command's documentation page (https://psmodule.io/Confluence/Functions///). Atlassian API references follow as additional .LINK entries. Private helpers are indented too but keep no docs link. --- .../Auth/Resolve-ConfluenceContext.ps1 | 5 +++-- .../private/Auth/Resolve-ConfluenceToken.ps1 | 5 +++-- .../Config/ConvertTo-ConfluenceHashtable.ps1 | 5 +++-- .../Config/Initialize-ConfluenceConfig.ps1 | 5 +++-- .../API/Invoke-ConfluenceRestMethod.ps1 | 21 ++++++++++++++----- .../Attachments/Add-ConfluenceAttachment.ps1 | 18 ++++++++++++---- .../Attachments/Get-ConfluenceAttachment.ps1 | 18 ++++++++++++---- .../Remove-ConfluenceAttachment.ps1 | 18 ++++++++++++---- .../public/Auth/Connect-Confluence.ps1 | 21 ++++++++++++++----- .../public/Auth/Disconnect-Confluence.ps1 | 15 ++++++++++--- .../public/Auth/Get-ConfluenceContext.ps1 | 15 ++++++++++--- .../BlogPosts/Get-ConfluenceBlogPost.ps1 | 18 ++++++++++++---- .../public/Comments/Add-ConfluenceComment.ps1 | 18 ++++++++++++---- .../public/Comments/Get-ConfluenceComment.ps1 | 18 ++++++++++++---- .../Comments/Remove-ConfluenceComment.ps1 | 18 ++++++++++++---- .../public/Config/Get-ConfluenceConfig.ps1 | 15 ++++++++++--- .../public/Config/Set-ConfluenceConfig.ps1 | 15 ++++++++++--- .../Get-ConfluenceContentProperty.ps1 | 18 ++++++++++++---- .../Remove-ConfluenceContentProperty.ps1 | 18 ++++++++++++---- .../Set-ConfluenceContentProperty.ps1 | 18 ++++++++++++---- .../public/Folders/Get-ConfluenceFolder.ps1 | 18 ++++++++++++---- .../public/Folders/New-ConfluenceFolder.ps1 | 18 ++++++++++++---- .../Folders/Remove-ConfluenceFolder.ps1 | 18 ++++++++++++---- .../public/Labels/Add-ConfluenceLabel.ps1 | 18 ++++++++++++---- .../public/Labels/Get-ConfluenceLabel.ps1 | 18 ++++++++++++---- .../public/Labels/Remove-ConfluenceLabel.ps1 | 18 ++++++++++++---- .../public/Pages/Get-ConfluenceDescendant.ps1 | 18 ++++++++++++---- .../public/Pages/Get-ConfluencePage.ps1 | 18 ++++++++++++---- .../public/Pages/Get-ConfluencePageChild.ps1 | 18 ++++++++++++---- .../Pages/Get-ConfluencePageVersion.ps1 | 18 ++++++++++++---- .../public/Pages/New-ConfluencePage.ps1 | 18 ++++++++++++---- .../public/Pages/Remove-ConfluencePage.ps1 | 18 ++++++++++++---- .../public/Pages/Set-ConfluencePage.ps1 | 18 ++++++++++++---- .../Get-ConfluenceRestriction.ps1 | 18 ++++++++++++---- .../public/Site/Get-ConfluenceSiteInfo.ps1 | 18 ++++++++++++---- .../public/Spaces/Get-ConfluenceSpace.ps1 | 18 ++++++++++++---- .../Spaces/Get-ConfluenceSpacePermission.ps1 | 18 ++++++++++++---- .../Spaces/Get-ConfluenceSpaceProperty.ps1 | 18 ++++++++++++---- .../Users/Get-ConfluenceCurrentUser.ps1 | 18 ++++++++++++---- 39 files changed, 498 insertions(+), 146 deletions(-) diff --git a/src/functions/private/Auth/Resolve-ConfluenceContext.ps1 b/src/functions/private/Auth/Resolve-ConfluenceContext.ps1 index 234c39f..5f65563 100644 --- a/src/functions/private/Auth/Resolve-ConfluenceContext.ps1 +++ b/src/functions/private/Auth/Resolve-ConfluenceContext.ps1 @@ -1,8 +1,9 @@ function Resolve-ConfluenceContext { <# - .SYNOPSIS + .SYNOPSIS Resolve a context argument into a usable credential-context object. - .DESCRIPTION + + .DESCRIPTION Accepts a context object/hashtable (returned as-is), a context name (looked up in the vault), or nothing (falls back to the default context recorded in the module configuration). diff --git a/src/functions/private/Auth/Resolve-ConfluenceToken.ps1 b/src/functions/private/Auth/Resolve-ConfluenceToken.ps1 index b4dab02..5736dca 100644 --- a/src/functions/private/Auth/Resolve-ConfluenceToken.ps1 +++ b/src/functions/private/Auth/Resolve-ConfluenceToken.ps1 @@ -1,8 +1,9 @@ function Resolve-ConfluenceToken { <# - .SYNOPSIS + .SYNOPSIS Return the plain-text API token from a SecureString or string. - .DESCRIPTION + + .DESCRIPTION The token is stored as a SecureString in the credential context; this converts it to the plain text needed to build the Basic auth header. #> diff --git a/src/functions/private/Config/ConvertTo-ConfluenceHashtable.ps1 b/src/functions/private/Config/ConvertTo-ConfluenceHashtable.ps1 index 00c8d28..f90f38e 100644 --- a/src/functions/private/Config/ConvertTo-ConfluenceHashtable.ps1 +++ b/src/functions/private/Config/ConvertTo-ConfluenceHashtable.ps1 @@ -1,8 +1,9 @@ function ConvertTo-ConfluenceHashtable { <# - .SYNOPSIS + .SYNOPSIS Convert a PSCustomObject (or hashtable) into a plain hashtable. - .DESCRIPTION + + .DESCRIPTION Used to turn the stored module-configuration context returned by Get-Context into a mutable hashtable. #> diff --git a/src/functions/private/Config/Initialize-ConfluenceConfig.ps1 b/src/functions/private/Config/Initialize-ConfluenceConfig.ps1 index 13095d8..7edd175 100644 --- a/src/functions/private/Config/Initialize-ConfluenceConfig.ps1 +++ b/src/functions/private/Config/Initialize-ConfluenceConfig.ps1 @@ -1,8 +1,9 @@ function Initialize-ConfluenceConfig { <# - .SYNOPSIS + .SYNOPSIS Load the module configuration from the context vault into memory. - .DESCRIPTION + + .DESCRIPTION Loads the stored 'Module' configuration context, creating it from the built-in defaults on first use. Missing default keys are backfilled. #> diff --git a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 index b8c79d7..53ae548 100644 --- a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 +++ b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 @@ -3,9 +3,10 @@ function Invoke-ConfluenceRestMethod { <# - .SYNOPSIS + .SYNOPSIS Call the Confluence REST API using a stored (or supplied) context. - .DESCRIPTION + + .DESCRIPTION The single generic entry point that every other Confluence function is built on. It resolves the credential context, builds the HTTP Basic auth header, sends the request, and surfaces API errors as terminating errors. @@ -16,11 +17,21 @@ function Invoke-ConfluenceRestMethod { rejection from other failures. Pass -Debug to emit the full request and response (status, headers, and body); the Authorization header is redacted. - .EXAMPLE + + .EXAMPLE + ```powershell Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/spaces' -Query @{ limit = 1 } - .LINK + ``` + + Calls the spaces endpoint directly and returns the raw response. + + .LINK + https://psmodule.io/Confluence/Functions/API/Invoke-ConfluenceRestMethod/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/intro/ - .LINK + + .LINK https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/ #> [CmdletBinding()] diff --git a/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 b/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 index a38ce88..43e07ce 100644 --- a/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 +++ b/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 @@ -1,16 +1,26 @@ function Add-ConfluenceAttachment { <# - .SYNOPSIS + .SYNOPSIS Upload an attachment to a page (read:content-details and write:attachment). - .DESCRIPTION + + .DESCRIPTION Uploads a file as a page attachment. Confluence exposes attachment creation only on the v1 content endpoint (/wiki/rest/api/content/{id}/child/attachment), which requires BOTH read:content-details and write:attachment. A token with write:attachment but without read:content-details is rejected with a scope-check 401. - .EXAMPLE + + .EXAMPLE + ```powershell Add-ConfluenceAttachment -PageId '12345' -Path ./diagram.png - .LINK + ``` + + Uploads diagram.png as an attachment on page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Attachments/Add-ConfluenceAttachment/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content---attachments/ #> [CmdletBinding(SupportsShouldProcess)] diff --git a/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 b/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 index 84153e4..17a0388 100644 --- a/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 +++ b/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 @@ -1,12 +1,22 @@ function Get-ConfluenceAttachment { <# - .SYNOPSIS + .SYNOPSIS Get page attachments (read:attachment). - .DESCRIPTION + + .DESCRIPTION Lists the attachments on a page, or returns a single attachment by id. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluenceAttachment -PageId '12345' - .LINK + ``` + + Lists the attachments on page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Attachments/Get-ConfluenceAttachment/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-attachment/ #> [CmdletBinding(DefaultParameterSetName = 'ByPage')] diff --git a/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 b/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 index 3ada77b..55bde99 100644 --- a/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 +++ b/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 @@ -1,12 +1,22 @@ function Remove-ConfluenceAttachment { <# - .SYNOPSIS + .SYNOPSIS Delete an attachment (delete:attachment). - .DESCRIPTION + + .DESCRIPTION Deletes an attachment by id. - .EXAMPLE + + .EXAMPLE + ```powershell Remove-ConfluenceAttachment -AttachmentId 'att12345' - .LINK + ``` + + Deletes the attachment with ID att12345. + + .LINK + https://psmodule.io/Confluence/Functions/Attachments/Remove-ConfluenceAttachment/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-attachment/ #> [CmdletBinding(SupportsShouldProcess)] diff --git a/src/functions/public/Auth/Connect-Confluence.ps1 b/src/functions/public/Auth/Connect-Confluence.ps1 index 783e73b..9428707 100644 --- a/src/functions/public/Auth/Connect-Confluence.ps1 +++ b/src/functions/public/Auth/Connect-Confluence.ps1 @@ -1,18 +1,29 @@ function Connect-Confluence { <# - .SYNOPSIS + .SYNOPSIS Connect to Confluence and store a credential profile in the context vault. - .DESCRIPTION + + .DESCRIPTION Validates the supplied credentials with a lightweight authenticated call, stores them as a named context (the token is kept as a SecureString), and records the context as the module default. A token that authenticates but lacks the read:space scope still connects (with a warning). - .EXAMPLE + + .EXAMPLE + ```powershell $token = Read-Host -AsSecureString Connect-Confluence -ApiBaseUri $uri -Username $user -Token $token -SpaceKey 'DOCS' - .LINK + ``` + + Connects with a scoped token and stores the profile with 'DOCS' as the default space. + + .LINK + https://psmodule.io/Confluence/Functions/Auth/Connect-Confluence/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/intro/#auth - .LINK + + .LINK https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/ #> [CmdletBinding(SupportsShouldProcess)] diff --git a/src/functions/public/Auth/Disconnect-Confluence.ps1 b/src/functions/public/Auth/Disconnect-Confluence.ps1 index 7f44c62..1ce16bf 100644 --- a/src/functions/public/Auth/Disconnect-Confluence.ps1 +++ b/src/functions/public/Auth/Disconnect-Confluence.ps1 @@ -1,12 +1,21 @@ function Disconnect-Confluence { <# - .SYNOPSIS + .SYNOPSIS Remove a stored Confluence credential profile. - .DESCRIPTION + + .DESCRIPTION Deletes the named context from the vault and clears it as the default context if it was the current default. - .EXAMPLE + + .EXAMPLE + ```powershell Disconnect-Confluence -Name 'sandbox' + ``` + + Removes the stored 'sandbox' credential profile. + + .LINK + https://psmodule.io/Confluence/Functions/Auth/Disconnect-Confluence/ #> [CmdletBinding(SupportsShouldProcess)] param( diff --git a/src/functions/public/Auth/Get-ConfluenceContext.ps1 b/src/functions/public/Auth/Get-ConfluenceContext.ps1 index 8e62a6f..e6482c6 100644 --- a/src/functions/public/Auth/Get-ConfluenceContext.ps1 +++ b/src/functions/public/Auth/Get-ConfluenceContext.ps1 @@ -1,12 +1,21 @@ function Get-ConfluenceContext { <# - .SYNOPSIS + .SYNOPSIS Get a stored Confluence credential profile. - .DESCRIPTION + + .DESCRIPTION Returns the named context, the default context, or (with -ListAvailable) all stored Confluence contexts. The token remains a SecureString. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluenceContext + ``` + + Returns the current default context. + + .LINK + https://psmodule.io/Confluence/Functions/Auth/Get-ConfluenceContext/ #> [CmdletBinding(DefaultParameterSetName = 'Single')] [OutputType([pscustomobject])] diff --git a/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 b/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 index 5c1d7ee..36c87c6 100644 --- a/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 +++ b/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 @@ -1,13 +1,23 @@ function Get-ConfluenceBlogPost { <# - .SYNOPSIS + .SYNOPSIS Get blog posts (read:page). - .DESCRIPTION + + .DESCRIPTION Returns a single blog post by id, the blog posts in a space, or all blog posts across the site, following pagination. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluenceBlogPost -SpaceId '123456' - .LINK + ``` + + Lists the blog posts in the space with ID 123456. + + .LINK + https://psmodule.io/Confluence/Functions/BlogPosts/Get-ConfluenceBlogPost/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-blog-post/ #> [CmdletBinding(DefaultParameterSetName = 'List')] diff --git a/src/functions/public/Comments/Add-ConfluenceComment.ps1 b/src/functions/public/Comments/Add-ConfluenceComment.ps1 index dda55f6..8340025 100644 --- a/src/functions/public/Comments/Add-ConfluenceComment.ps1 +++ b/src/functions/public/Comments/Add-ConfluenceComment.ps1 @@ -1,12 +1,22 @@ function Add-ConfluenceComment { <# - .SYNOPSIS + .SYNOPSIS Add a footer comment to a page (write:comment). - .DESCRIPTION + + .DESCRIPTION Creates a footer comment on the given page. - .EXAMPLE + + .EXAMPLE + ```powershell Add-ConfluenceComment -PageId '12345' -Body '

Nice page.

' - .LINK + ``` + + Adds a footer comment to page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Comments/Add-ConfluenceComment/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-comment/ #> [CmdletBinding(SupportsShouldProcess)] diff --git a/src/functions/public/Comments/Get-ConfluenceComment.ps1 b/src/functions/public/Comments/Get-ConfluenceComment.ps1 index ed0a199..a1ddf80 100644 --- a/src/functions/public/Comments/Get-ConfluenceComment.ps1 +++ b/src/functions/public/Comments/Get-ConfluenceComment.ps1 @@ -1,12 +1,22 @@ function Get-ConfluenceComment { <# - .SYNOPSIS + .SYNOPSIS List the footer comments on a page (read:comment). - .DESCRIPTION + + .DESCRIPTION Returns every footer comment on the page, following pagination. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluenceComment -PageId '12345' - .LINK + ``` + + Lists the footer comments on page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Comments/Get-ConfluenceComment/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-comment/ #> [CmdletBinding()] diff --git a/src/functions/public/Comments/Remove-ConfluenceComment.ps1 b/src/functions/public/Comments/Remove-ConfluenceComment.ps1 index 69f154b..3fbf246 100644 --- a/src/functions/public/Comments/Remove-ConfluenceComment.ps1 +++ b/src/functions/public/Comments/Remove-ConfluenceComment.ps1 @@ -1,12 +1,22 @@ function Remove-ConfluenceComment { <# - .SYNOPSIS + .SYNOPSIS Delete a footer comment (delete:comment). - .DESCRIPTION + + .DESCRIPTION Permanently deletes a footer comment by id. - .EXAMPLE + + .EXAMPLE + ```powershell Remove-ConfluenceComment -CommentId '55555' - .LINK + ``` + + Deletes the footer comment with ID 55555. + + .LINK + https://psmodule.io/Confluence/Functions/Comments/Remove-ConfluenceComment/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-comment/ #> [CmdletBinding(SupportsShouldProcess)] diff --git a/src/functions/public/Config/Get-ConfluenceConfig.ps1 b/src/functions/public/Config/Get-ConfluenceConfig.ps1 index 3dd4299..c6ccafd 100644 --- a/src/functions/public/Config/Get-ConfluenceConfig.ps1 +++ b/src/functions/public/Config/Get-ConfluenceConfig.ps1 @@ -1,12 +1,21 @@ function Get-ConfluenceConfig { <# - .SYNOPSIS + .SYNOPSIS Get the Confluence module configuration. - .DESCRIPTION + + .DESCRIPTION Returns the whole configuration hashtable, or the value of a single named configuration item. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluenceConfig -Name 'DefaultContext' + ``` + + Gets the DefaultContext value from the module configuration. + + .LINK + https://psmodule.io/Confluence/Functions/Config/Get-ConfluenceConfig/ #> [CmdletBinding()] param( diff --git a/src/functions/public/Config/Set-ConfluenceConfig.ps1 b/src/functions/public/Config/Set-ConfluenceConfig.ps1 index 63f12d3..9114da0 100644 --- a/src/functions/public/Config/Set-ConfluenceConfig.ps1 +++ b/src/functions/public/Config/Set-ConfluenceConfig.ps1 @@ -1,12 +1,21 @@ function Set-ConfluenceConfig { <# - .SYNOPSIS + .SYNOPSIS Set a Confluence module configuration value. - .DESCRIPTION + + .DESCRIPTION Updates a single configuration item and persists the configuration to the context vault. - .EXAMPLE + + .EXAMPLE + ```powershell Set-ConfluenceConfig -Name 'PerPage' -Value 50 + ``` + + Sets the PerPage configuration value to 50. + + .LINK + https://psmodule.io/Confluence/Functions/Config/Set-ConfluenceConfig/ #> [CmdletBinding(SupportsShouldProcess)] param( diff --git a/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 b/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 index 0c5b983..694456b 100644 --- a/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 +++ b/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 @@ -1,15 +1,25 @@ function Get-ConfluenceContentProperty { <# - .SYNOPSIS + .SYNOPSIS Get content properties of a page (read:page). - .DESCRIPTION + + .DESCRIPTION Returns all content properties on a page, or the single property with the given key. In v2, a page's content properties are governed by the page's own scope (read:page); the read:content.property scope applies to the v1 property API. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluenceContentProperty -PageId '12345' -Key 'my-prop' - .LINK + ``` + + Gets the 'my-prop' content property from page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/ContentProperties/Get-ConfluenceContentProperty/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-content-properties/ #> [CmdletBinding()] diff --git a/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 b/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 index 8c9e8e8..847605e 100644 --- a/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 +++ b/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 @@ -1,13 +1,23 @@ function Remove-ConfluenceContentProperty { <# - .SYNOPSIS + .SYNOPSIS Delete a content property from a page (read:page and write:page). - .DESCRIPTION + + .DESCRIPTION Deletes a content property by its id. In v2, page content properties are governed by the page's own scope (read:page and write:page). - .EXAMPLE + + .EXAMPLE + ```powershell Remove-ConfluenceContentProperty -PageId '12345' -PropertyId '98765' - .LINK + ``` + + Deletes the content property with ID 98765 from page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/ContentProperties/Remove-ConfluenceContentProperty/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-content-properties/ #> [CmdletBinding(SupportsShouldProcess)] diff --git a/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 b/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 index d4e7dab..d77585a 100644 --- a/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 +++ b/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 @@ -1,15 +1,25 @@ function Set-ConfluenceContentProperty { <# - .SYNOPSIS + .SYNOPSIS Create or update a content property on a page (read:page and write:page). - .DESCRIPTION + + .DESCRIPTION Creates the property if it does not exist, or updates it (incrementing its version) if it does. In v2, page content properties are governed by the page's own scope: reading requires read:page and writing requires write:page. - .EXAMPLE + + .EXAMPLE + ```powershell Set-ConfluenceContentProperty -PageId '12345' -Key 'owner' -Value @{ team = 'ai' } - .LINK + ``` + + Creates or updates the 'owner' content property on page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/ContentProperties/Set-ConfluenceContentProperty/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-content-properties/ #> [CmdletBinding(SupportsShouldProcess)] diff --git a/src/functions/public/Folders/Get-ConfluenceFolder.ps1 b/src/functions/public/Folders/Get-ConfluenceFolder.ps1 index d21b42a..55ff2f3 100644 --- a/src/functions/public/Folders/Get-ConfluenceFolder.ps1 +++ b/src/functions/public/Folders/Get-ConfluenceFolder.ps1 @@ -1,12 +1,22 @@ function Get-ConfluenceFolder { <# - .SYNOPSIS + .SYNOPSIS Get a Confluence folder by id (read:folder). - .DESCRIPTION + + .DESCRIPTION Returns a single folder. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluenceFolder -FolderId '67890' - .LINK + ``` + + Gets the folder with ID 67890. + + .LINK + https://psmodule.io/Confluence/Functions/Folders/Get-ConfluenceFolder/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-folder/ #> [CmdletBinding()] diff --git a/src/functions/public/Folders/New-ConfluenceFolder.ps1 b/src/functions/public/Folders/New-ConfluenceFolder.ps1 index 19041a8..9b0270e 100644 --- a/src/functions/public/Folders/New-ConfluenceFolder.ps1 +++ b/src/functions/public/Folders/New-ConfluenceFolder.ps1 @@ -1,12 +1,22 @@ function New-ConfluenceFolder { <# - .SYNOPSIS + .SYNOPSIS Create a Confluence folder (write:folder). - .DESCRIPTION + + .DESCRIPTION Creates a folder in a space, optionally beneath a parent page or folder. - .EXAMPLE + + .EXAMPLE + ```powershell New-ConfluenceFolder -SpaceId $spaceId -Title 'Archive' -ParentId $pageId - .LINK + ``` + + Creates a folder named 'Archive' beneath the given parent. + + .LINK + https://psmodule.io/Confluence/Functions/Folders/New-ConfluenceFolder/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-folder/ #> [CmdletBinding(SupportsShouldProcess)] diff --git a/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 b/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 index f04c8dd..83584eb 100644 --- a/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 +++ b/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 @@ -1,12 +1,22 @@ function Remove-ConfluenceFolder { <# - .SYNOPSIS + .SYNOPSIS Delete a Confluence folder (delete:folder). - .DESCRIPTION + + .DESCRIPTION Moves a folder to the trash. - .EXAMPLE + + .EXAMPLE + ```powershell Remove-ConfluenceFolder -FolderId '67890' - .LINK + ``` + + Moves the folder with ID 67890 to the trash. + + .LINK + https://psmodule.io/Confluence/Functions/Folders/Remove-ConfluenceFolder/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-folder/ #> [CmdletBinding(SupportsShouldProcess)] diff --git a/src/functions/public/Labels/Add-ConfluenceLabel.ps1 b/src/functions/public/Labels/Add-ConfluenceLabel.ps1 index d5b84d2..b3a7612 100644 --- a/src/functions/public/Labels/Add-ConfluenceLabel.ps1 +++ b/src/functions/public/Labels/Add-ConfluenceLabel.ps1 @@ -1,14 +1,24 @@ function Add-ConfluenceLabel { <# - .SYNOPSIS + .SYNOPSIS Add one or more labels to a page (read:label and write:label). - .DESCRIPTION + + .DESCRIPTION Adds labels to a page. Confluence exposes label creation only on the v1 content endpoint (/wiki/rest/api/content/{id}/label), which the granular read:label and write:label scopes authorise. - .EXAMPLE + + .EXAMPLE + ```powershell Add-ConfluenceLabel -PageId '12345' -Label 'docs', 'published' - .LINK + ``` + + Adds the 'docs' and 'published' labels to page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Labels/Add-ConfluenceLabel/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-labels/ #> [CmdletBinding(SupportsShouldProcess)] diff --git a/src/functions/public/Labels/Get-ConfluenceLabel.ps1 b/src/functions/public/Labels/Get-ConfluenceLabel.ps1 index fdb1b32..fba6234 100644 --- a/src/functions/public/Labels/Get-ConfluenceLabel.ps1 +++ b/src/functions/public/Labels/Get-ConfluenceLabel.ps1 @@ -1,14 +1,24 @@ function Get-ConfluenceLabel { <# - .SYNOPSIS + .SYNOPSIS List the labels on a page (read:page). - .DESCRIPTION + + .DESCRIPTION Returns every label on the page, following pagination. In v2 a page's labels are read under the page's own scope (read:page); the read:label scope covers only the site-wide GET /labels endpoint. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluenceLabel -PageId '12345' - .LINK + ``` + + Lists the labels on page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Labels/Get-ConfluenceLabel/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-label/ #> [CmdletBinding()] diff --git a/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 b/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 index 619bb9b..beecdb5 100644 --- a/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 +++ b/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 @@ -1,12 +1,22 @@ function Remove-ConfluenceLabel { <# - .SYNOPSIS + .SYNOPSIS Remove a label from a page (write:label). - .DESCRIPTION + + .DESCRIPTION Removes a label from a page via the v1 content endpoint. - .EXAMPLE + + .EXAMPLE + ```powershell Remove-ConfluenceLabel -PageId '12345' -Label 'docs' - .LINK + ``` + + Removes the 'docs' label from page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Labels/Remove-ConfluenceLabel/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-labels/ #> [CmdletBinding(SupportsShouldProcess)] diff --git a/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 b/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 index ee495bb..91aefdd 100644 --- a/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 +++ b/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 @@ -1,13 +1,23 @@ function Get-ConfluenceDescendant { <# - .SYNOPSIS + .SYNOPSIS List all descendants of a page (read:hierarchical-content). - .DESCRIPTION + + .DESCRIPTION Returns every descendant (child, grandchild, ...) of a page, following pagination automatically. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluenceDescendant -PageId '12345' - .LINK + ``` + + Lists all descendants of page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Pages/Get-ConfluenceDescendant/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-descendants/ #> [CmdletBinding()] diff --git a/src/functions/public/Pages/Get-ConfluencePage.ps1 b/src/functions/public/Pages/Get-ConfluencePage.ps1 index 419abc0..70c0a9e 100644 --- a/src/functions/public/Pages/Get-ConfluencePage.ps1 +++ b/src/functions/public/Pages/Get-ConfluencePage.ps1 @@ -1,12 +1,22 @@ function Get-ConfluencePage { <# - .SYNOPSIS + .SYNOPSIS Get a Confluence page by id (read:page). - .DESCRIPTION + + .DESCRIPTION Returns a single page, including its body in the requested format. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluencePage -PageId '12345' - .LINK + ``` + + Gets page 12345, including its body in storage format. + + .LINK + https://psmodule.io/Confluence/Functions/Pages/Get-ConfluencePage/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/ #> [CmdletBinding()] diff --git a/src/functions/public/Pages/Get-ConfluencePageChild.ps1 b/src/functions/public/Pages/Get-ConfluencePageChild.ps1 index df8c8f9..33d7fb9 100644 --- a/src/functions/public/Pages/Get-ConfluencePageChild.ps1 +++ b/src/functions/public/Pages/Get-ConfluencePageChild.ps1 @@ -1,12 +1,22 @@ function Get-ConfluencePageChild { <# - .SYNOPSIS + .SYNOPSIS List the direct child pages of a page (read:page). - .DESCRIPTION + + .DESCRIPTION Returns every direct child page, following pagination automatically. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluencePageChild -PageId '12345' - .LINK + ``` + + Lists the direct child pages of page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Pages/Get-ConfluencePageChild/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-children/ #> [CmdletBinding()] diff --git a/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 b/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 index 13489f6..af47530 100644 --- a/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 +++ b/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 @@ -1,12 +1,22 @@ function Get-ConfluencePageVersion { <# - .SYNOPSIS + .SYNOPSIS List the version history of a page (read:page). - .DESCRIPTION + + .DESCRIPTION Returns every version entry for a page, newest first, following pagination. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluencePageVersion -PageId '12345' - .LINK + ``` + + Lists the version history of page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Pages/Get-ConfluencePageVersion/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-version/ #> [CmdletBinding()] diff --git a/src/functions/public/Pages/New-ConfluencePage.ps1 b/src/functions/public/Pages/New-ConfluencePage.ps1 index 402d0cc..4ba5cc7 100644 --- a/src/functions/public/Pages/New-ConfluencePage.ps1 +++ b/src/functions/public/Pages/New-ConfluencePage.ps1 @@ -1,13 +1,23 @@ function New-ConfluencePage { <# - .SYNOPSIS + .SYNOPSIS Create a Confluence page (write:page). - .DESCRIPTION + + .DESCRIPTION Creates a page in a space, optionally beneath a parent page. A page with no parent is created under the space home page. - .EXAMPLE + + .EXAMPLE + ```powershell New-ConfluencePage -SpaceId $spaceId -Title 'Docs' -Body '

Hello

' - .LINK + ``` + + Creates a page titled 'Docs' in the given space. + + .LINK + https://psmodule.io/Confluence/Functions/Pages/New-ConfluencePage/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/ #> [CmdletBinding(SupportsShouldProcess)] diff --git a/src/functions/public/Pages/Remove-ConfluencePage.ps1 b/src/functions/public/Pages/Remove-ConfluencePage.ps1 index 1a63e27..2d4e70e 100644 --- a/src/functions/public/Pages/Remove-ConfluencePage.ps1 +++ b/src/functions/public/Pages/Remove-ConfluencePage.ps1 @@ -1,14 +1,24 @@ function Remove-ConfluencePage { <# - .SYNOPSIS + .SYNOPSIS Delete a Confluence page (delete:page). - .DESCRIPTION + + .DESCRIPTION Moves a page to the trash. With -Recurse, direct and nested child pages are removed first so that a whole subtree can be deleted. With -Purge the page is permanently deleted (only valid for already-trashed pages). - .EXAMPLE + + .EXAMPLE + ```powershell Remove-ConfluencePage -PageId '12345' -Recurse - .LINK + ``` + + Moves page 12345 and its child pages to the trash. + + .LINK + https://psmodule.io/Confluence/Functions/Pages/Remove-ConfluencePage/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/ #> [CmdletBinding(SupportsShouldProcess)] diff --git a/src/functions/public/Pages/Set-ConfluencePage.ps1 b/src/functions/public/Pages/Set-ConfluencePage.ps1 index c7ab81a..ef68f7b 100644 --- a/src/functions/public/Pages/Set-ConfluencePage.ps1 +++ b/src/functions/public/Pages/Set-ConfluencePage.ps1 @@ -1,15 +1,25 @@ function Set-ConfluencePage { <# - .SYNOPSIS + .SYNOPSIS Update a Confluence page (read:page and write:page). - .DESCRIPTION + + .DESCRIPTION Updates the title and/or body of a page, automatically incrementing the version number. Unspecified fields keep their current values. The current page is read first (read:page) to preserve unspecified fields and to obtain the version number, then written back (write:page). - .EXAMPLE + + .EXAMPLE + ```powershell Set-ConfluencePage -PageId '12345' -Body '

Updated

' - .LINK + ``` + + Updates the body of page 12345, incrementing its version. + + .LINK + https://psmodule.io/Confluence/Functions/Pages/Set-ConfluencePage/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/ #> [CmdletBinding(SupportsShouldProcess)] diff --git a/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 b/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 index da10bd8..b45a3a7 100644 --- a/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 +++ b/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 @@ -1,15 +1,25 @@ function Get-ConfluenceRestriction { <# - .SYNOPSIS + .SYNOPSIS Get the content restrictions on a page (read:content-details). - .DESCRIPTION + + .DESCRIPTION Returns the read/update restrictions applied to a page. Reading restrictions requires read:content-details; the read:content.restriction scope only covers the narrow byOperation/{op}/user|group status checks. Content restrictions are part of the v1 content API (see the link). - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluenceRestriction -PageId '12345' - .LINK + ``` + + Gets the read/update restrictions on page 12345. + + .LINK + https://psmodule.io/Confluence/Functions/Restrictions/Get-ConfluenceRestriction/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-restrictions/ #> [CmdletBinding()] diff --git a/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 b/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 index 57fa6fc..1bb4568 100644 --- a/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 +++ b/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 @@ -1,15 +1,25 @@ function Get-ConfluenceSiteInfo { <# - .SYNOPSIS + .SYNOPSIS Get basic information about the connected Confluence site (read:space or read:page). - .DESCRIPTION + + .DESCRIPTION Returns the site's cloud id (parsed from the API-gateway base URI) and the browsable site (wiki) base URL (read from the API's _links.base). A scoped v2 token cannot reach the dedicated site/settings endpoints (site name, edition, build number) — those are v1-only and are rejected. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluenceSiteInfo - .LINK + ``` + + Returns the cloud ID and browsable site URL for the connected site. + + .LINK + https://psmodule.io/Confluence/Functions/Site/Get-ConfluenceSiteInfo/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/ #> [CmdletBinding()] diff --git a/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 b/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 index 7d465d0..da91b8c 100644 --- a/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 +++ b/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 @@ -1,13 +1,23 @@ function Get-ConfluenceSpace { <# - .SYNOPSIS + .SYNOPSIS Get a Confluence space (read:space). - .DESCRIPTION + + .DESCRIPTION Returns a space by key or by id. When neither is supplied the default space key from the connected context is used. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluenceSpace -Key 'DOCS' - .LINK + ``` + + Gets the space with key 'DOCS'. + + .LINK + https://psmodule.io/Confluence/Functions/Spaces/Get-ConfluenceSpace/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/ #> [CmdletBinding(DefaultParameterSetName = 'ByKey')] diff --git a/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 b/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 index 6b6a3fa..885ae91 100644 --- a/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 +++ b/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 @@ -1,17 +1,27 @@ function Get-ConfluenceSpacePermission { <# - .SYNOPSIS + .SYNOPSIS List the permission assignments on a space (read:space). - .DESCRIPTION + + .DESCRIPTION Returns every permission assignment in the space — each entry pairs a principal (user or group) with an operation (for example read/space, create/page, delete/page, restrict_content/space). This is the space's whole permission table, not a filtered "effective permissions for the current user" view (Confluence exposes that only on the v1 operations expansion, which a granular v2 token cannot call). - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluenceSpacePermission -SpaceId '123456' - .LINK + ``` + + Lists the permission assignments in the space with ID 123456. + + .LINK + https://psmodule.io/Confluence/Functions/Spaces/Get-ConfluenceSpacePermission/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space-permissions/ #> [CmdletBinding()] diff --git a/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 b/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 index bbb67a5..38ae6d7 100644 --- a/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 +++ b/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 @@ -1,12 +1,22 @@ function Get-ConfluenceSpaceProperty { <# - .SYNOPSIS + .SYNOPSIS Get space properties (read:space). - .DESCRIPTION + + .DESCRIPTION Returns all properties on a space, or the single property with the given key. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluenceSpaceProperty -SpaceId '123456' - .LINK + ``` + + Gets all properties on the space with ID 123456. + + .LINK + https://psmodule.io/Confluence/Functions/Spaces/Get-ConfluenceSpaceProperty/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space-properties/ #> [CmdletBinding()] diff --git a/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 b/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 index 77c37bb..3d66a5f 100644 --- a/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 +++ b/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 @@ -1,16 +1,26 @@ function Get-ConfluenceCurrentUser { <# - .SYNOPSIS + .SYNOPSIS Get the current (authenticated) user — Confluence's "me" endpoint (read:content-details). - .DESCRIPTION + + .DESCRIPTION Returns the account behind the connected token via the Confluence current-user endpoint (v1 /wiki/rest/api/user/current), which requires read:content-details. The read:user scope only covers the anonymous-user and group-membership endpoints, so a token without read:content-details is rejected with a scope-check 401. - .EXAMPLE + + .EXAMPLE + ```powershell Get-ConfluenceCurrentUser - .LINK + ``` + + Returns the account behind the connected token. + + .LINK + https://psmodule.io/Confluence/Functions/Users/Get-ConfluenceCurrentUser/ + + .LINK https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-users/ #> [CmdletBinding()] From fbfdad2c70b497c3b86c2242245e6e707ad85d0f Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 01:07:01 +0200 Subject: [PATCH 06/21] Green the source-code and docs pipelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test-SourceCode requires a test for every public function; integration tests are deferred until the repository's Confluence credentials are configured, so mark each public function with #SkipTest:FunctionTest and a reason. Build-Docs runs markdownlint on the generated docs, which flagged the bare Confluence API URL in the Connect-Confluence -ApiBaseUri help — escape it with backticks (MD034). --- src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 | 1 + .../public/Attachments/Add-ConfluenceAttachment.ps1 | 3 ++- .../public/Attachments/Get-ConfluenceAttachment.ps1 | 3 ++- .../public/Attachments/Remove-ConfluenceAttachment.ps1 | 3 ++- src/functions/public/Auth/Connect-Confluence.ps1 | 5 +++-- src/functions/public/Auth/Disconnect-Confluence.ps1 | 3 ++- src/functions/public/Auth/Get-ConfluenceContext.ps1 | 3 ++- src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 | 3 ++- src/functions/public/Comments/Add-ConfluenceComment.ps1 | 3 ++- src/functions/public/Comments/Get-ConfluenceComment.ps1 | 3 ++- src/functions/public/Comments/Remove-ConfluenceComment.ps1 | 3 ++- src/functions/public/Config/Get-ConfluenceConfig.ps1 | 3 ++- src/functions/public/Config/Set-ConfluenceConfig.ps1 | 3 ++- .../ContentProperties/Get-ConfluenceContentProperty.ps1 | 3 ++- .../ContentProperties/Remove-ConfluenceContentProperty.ps1 | 3 ++- .../ContentProperties/Set-ConfluenceContentProperty.ps1 | 3 ++- src/functions/public/Folders/Get-ConfluenceFolder.ps1 | 3 ++- src/functions/public/Folders/New-ConfluenceFolder.ps1 | 3 ++- src/functions/public/Folders/Remove-ConfluenceFolder.ps1 | 3 ++- src/functions/public/Labels/Add-ConfluenceLabel.ps1 | 3 ++- src/functions/public/Labels/Get-ConfluenceLabel.ps1 | 3 ++- src/functions/public/Labels/Remove-ConfluenceLabel.ps1 | 3 ++- src/functions/public/Pages/Get-ConfluenceDescendant.ps1 | 3 ++- src/functions/public/Pages/Get-ConfluencePage.ps1 | 3 ++- src/functions/public/Pages/Get-ConfluencePageChild.ps1 | 3 ++- src/functions/public/Pages/Get-ConfluencePageVersion.ps1 | 3 ++- src/functions/public/Pages/New-ConfluencePage.ps1 | 3 ++- src/functions/public/Pages/Remove-ConfluencePage.ps1 | 3 ++- src/functions/public/Pages/Set-ConfluencePage.ps1 | 3 ++- .../public/Restrictions/Get-ConfluenceRestriction.ps1 | 3 ++- src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 | 3 ++- src/functions/public/Spaces/Get-ConfluenceSpace.ps1 | 3 ++- .../public/Spaces/Get-ConfluenceSpacePermission.ps1 | 3 ++- src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 | 3 ++- src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 | 3 ++- 35 files changed, 70 insertions(+), 35 deletions(-) diff --git a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 index 53ae548..8139ac8 100644 --- a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 +++ b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 @@ -1,6 +1,7 @@ #Requires -Version 7.0 #Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.6' } +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. function Invoke-ConfluenceRestMethod { <# .SYNOPSIS diff --git a/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 b/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 index 43e07ce..ce3adf8 100644 --- a/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 +++ b/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 @@ -1,4 +1,5 @@ -function Add-ConfluenceAttachment { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Add-ConfluenceAttachment { <# .SYNOPSIS Upload an attachment to a page (read:content-details and write:attachment). diff --git a/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 b/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 index 17a0388..a4807b7 100644 --- a/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 +++ b/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluenceAttachment { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluenceAttachment { <# .SYNOPSIS Get page attachments (read:attachment). diff --git a/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 b/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 index 55bde99..d08ba7b 100644 --- a/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 +++ b/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 @@ -1,4 +1,5 @@ -function Remove-ConfluenceAttachment { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Remove-ConfluenceAttachment { <# .SYNOPSIS Delete an attachment (delete:attachment). diff --git a/src/functions/public/Auth/Connect-Confluence.ps1 b/src/functions/public/Auth/Connect-Confluence.ps1 index 9428707..def9f72 100644 --- a/src/functions/public/Auth/Connect-Confluence.ps1 +++ b/src/functions/public/Auth/Connect-Confluence.ps1 @@ -1,4 +1,5 @@ -function Connect-Confluence { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Connect-Confluence { <# .SYNOPSIS Connect to Confluence and store a credential profile in the context vault. @@ -29,7 +30,7 @@ [CmdletBinding(SupportsShouldProcess)] [OutputType([pscustomobject])] param( - # The Confluence API-gateway base URI, e.g. 'https://api.atlassian.com/ex/confluence/'. + # The Confluence API-gateway base URI, for example `https://api.atlassian.com/ex/confluence/`. [Parameter(Mandatory)] [string]$ApiBaseUri, diff --git a/src/functions/public/Auth/Disconnect-Confluence.ps1 b/src/functions/public/Auth/Disconnect-Confluence.ps1 index 1ce16bf..7009cb3 100644 --- a/src/functions/public/Auth/Disconnect-Confluence.ps1 +++ b/src/functions/public/Auth/Disconnect-Confluence.ps1 @@ -1,4 +1,5 @@ -function Disconnect-Confluence { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Disconnect-Confluence { <# .SYNOPSIS Remove a stored Confluence credential profile. diff --git a/src/functions/public/Auth/Get-ConfluenceContext.ps1 b/src/functions/public/Auth/Get-ConfluenceContext.ps1 index e6482c6..9791c38 100644 --- a/src/functions/public/Auth/Get-ConfluenceContext.ps1 +++ b/src/functions/public/Auth/Get-ConfluenceContext.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluenceContext { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluenceContext { <# .SYNOPSIS Get a stored Confluence credential profile. diff --git a/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 b/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 index 36c87c6..ddf6482 100644 --- a/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 +++ b/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluenceBlogPost { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluenceBlogPost { <# .SYNOPSIS Get blog posts (read:page). diff --git a/src/functions/public/Comments/Add-ConfluenceComment.ps1 b/src/functions/public/Comments/Add-ConfluenceComment.ps1 index 8340025..81c5544 100644 --- a/src/functions/public/Comments/Add-ConfluenceComment.ps1 +++ b/src/functions/public/Comments/Add-ConfluenceComment.ps1 @@ -1,4 +1,5 @@ -function Add-ConfluenceComment { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Add-ConfluenceComment { <# .SYNOPSIS Add a footer comment to a page (write:comment). diff --git a/src/functions/public/Comments/Get-ConfluenceComment.ps1 b/src/functions/public/Comments/Get-ConfluenceComment.ps1 index a1ddf80..78684f4 100644 --- a/src/functions/public/Comments/Get-ConfluenceComment.ps1 +++ b/src/functions/public/Comments/Get-ConfluenceComment.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluenceComment { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluenceComment { <# .SYNOPSIS List the footer comments on a page (read:comment). diff --git a/src/functions/public/Comments/Remove-ConfluenceComment.ps1 b/src/functions/public/Comments/Remove-ConfluenceComment.ps1 index 3fbf246..9a92a74 100644 --- a/src/functions/public/Comments/Remove-ConfluenceComment.ps1 +++ b/src/functions/public/Comments/Remove-ConfluenceComment.ps1 @@ -1,4 +1,5 @@ -function Remove-ConfluenceComment { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Remove-ConfluenceComment { <# .SYNOPSIS Delete a footer comment (delete:comment). diff --git a/src/functions/public/Config/Get-ConfluenceConfig.ps1 b/src/functions/public/Config/Get-ConfluenceConfig.ps1 index c6ccafd..8057009 100644 --- a/src/functions/public/Config/Get-ConfluenceConfig.ps1 +++ b/src/functions/public/Config/Get-ConfluenceConfig.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluenceConfig { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluenceConfig { <# .SYNOPSIS Get the Confluence module configuration. diff --git a/src/functions/public/Config/Set-ConfluenceConfig.ps1 b/src/functions/public/Config/Set-ConfluenceConfig.ps1 index 9114da0..51d0343 100644 --- a/src/functions/public/Config/Set-ConfluenceConfig.ps1 +++ b/src/functions/public/Config/Set-ConfluenceConfig.ps1 @@ -1,4 +1,5 @@ -function Set-ConfluenceConfig { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Set-ConfluenceConfig { <# .SYNOPSIS Set a Confluence module configuration value. diff --git a/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 b/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 index 694456b..ee315eb 100644 --- a/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 +++ b/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluenceContentProperty { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluenceContentProperty { <# .SYNOPSIS Get content properties of a page (read:page). diff --git a/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 b/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 index 847605e..6621e58 100644 --- a/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 +++ b/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 @@ -1,4 +1,5 @@ -function Remove-ConfluenceContentProperty { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Remove-ConfluenceContentProperty { <# .SYNOPSIS Delete a content property from a page (read:page and write:page). diff --git a/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 b/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 index d77585a..9191726 100644 --- a/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 +++ b/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 @@ -1,4 +1,5 @@ -function Set-ConfluenceContentProperty { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Set-ConfluenceContentProperty { <# .SYNOPSIS Create or update a content property on a page (read:page and write:page). diff --git a/src/functions/public/Folders/Get-ConfluenceFolder.ps1 b/src/functions/public/Folders/Get-ConfluenceFolder.ps1 index 55ff2f3..f9a03ec 100644 --- a/src/functions/public/Folders/Get-ConfluenceFolder.ps1 +++ b/src/functions/public/Folders/Get-ConfluenceFolder.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluenceFolder { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluenceFolder { <# .SYNOPSIS Get a Confluence folder by id (read:folder). diff --git a/src/functions/public/Folders/New-ConfluenceFolder.ps1 b/src/functions/public/Folders/New-ConfluenceFolder.ps1 index 9b0270e..73ecedb 100644 --- a/src/functions/public/Folders/New-ConfluenceFolder.ps1 +++ b/src/functions/public/Folders/New-ConfluenceFolder.ps1 @@ -1,4 +1,5 @@ -function New-ConfluenceFolder { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function New-ConfluenceFolder { <# .SYNOPSIS Create a Confluence folder (write:folder). diff --git a/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 b/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 index 83584eb..19dc6a8 100644 --- a/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 +++ b/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 @@ -1,4 +1,5 @@ -function Remove-ConfluenceFolder { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Remove-ConfluenceFolder { <# .SYNOPSIS Delete a Confluence folder (delete:folder). diff --git a/src/functions/public/Labels/Add-ConfluenceLabel.ps1 b/src/functions/public/Labels/Add-ConfluenceLabel.ps1 index b3a7612..1558f79 100644 --- a/src/functions/public/Labels/Add-ConfluenceLabel.ps1 +++ b/src/functions/public/Labels/Add-ConfluenceLabel.ps1 @@ -1,4 +1,5 @@ -function Add-ConfluenceLabel { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Add-ConfluenceLabel { <# .SYNOPSIS Add one or more labels to a page (read:label and write:label). diff --git a/src/functions/public/Labels/Get-ConfluenceLabel.ps1 b/src/functions/public/Labels/Get-ConfluenceLabel.ps1 index fba6234..325f779 100644 --- a/src/functions/public/Labels/Get-ConfluenceLabel.ps1 +++ b/src/functions/public/Labels/Get-ConfluenceLabel.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluenceLabel { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluenceLabel { <# .SYNOPSIS List the labels on a page (read:page). diff --git a/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 b/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 index beecdb5..d6dabf2 100644 --- a/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 +++ b/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 @@ -1,4 +1,5 @@ -function Remove-ConfluenceLabel { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Remove-ConfluenceLabel { <# .SYNOPSIS Remove a label from a page (write:label). diff --git a/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 b/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 index 91aefdd..94794ab 100644 --- a/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 +++ b/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluenceDescendant { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluenceDescendant { <# .SYNOPSIS List all descendants of a page (read:hierarchical-content). diff --git a/src/functions/public/Pages/Get-ConfluencePage.ps1 b/src/functions/public/Pages/Get-ConfluencePage.ps1 index 70c0a9e..5fd019a 100644 --- a/src/functions/public/Pages/Get-ConfluencePage.ps1 +++ b/src/functions/public/Pages/Get-ConfluencePage.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluencePage { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluencePage { <# .SYNOPSIS Get a Confluence page by id (read:page). diff --git a/src/functions/public/Pages/Get-ConfluencePageChild.ps1 b/src/functions/public/Pages/Get-ConfluencePageChild.ps1 index 33d7fb9..859556e 100644 --- a/src/functions/public/Pages/Get-ConfluencePageChild.ps1 +++ b/src/functions/public/Pages/Get-ConfluencePageChild.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluencePageChild { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluencePageChild { <# .SYNOPSIS List the direct child pages of a page (read:page). diff --git a/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 b/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 index af47530..62b40c3 100644 --- a/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 +++ b/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluencePageVersion { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluencePageVersion { <# .SYNOPSIS List the version history of a page (read:page). diff --git a/src/functions/public/Pages/New-ConfluencePage.ps1 b/src/functions/public/Pages/New-ConfluencePage.ps1 index 4ba5cc7..421a7ec 100644 --- a/src/functions/public/Pages/New-ConfluencePage.ps1 +++ b/src/functions/public/Pages/New-ConfluencePage.ps1 @@ -1,4 +1,5 @@ -function New-ConfluencePage { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function New-ConfluencePage { <# .SYNOPSIS Create a Confluence page (write:page). diff --git a/src/functions/public/Pages/Remove-ConfluencePage.ps1 b/src/functions/public/Pages/Remove-ConfluencePage.ps1 index 2d4e70e..85f4520 100644 --- a/src/functions/public/Pages/Remove-ConfluencePage.ps1 +++ b/src/functions/public/Pages/Remove-ConfluencePage.ps1 @@ -1,4 +1,5 @@ -function Remove-ConfluencePage { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Remove-ConfluencePage { <# .SYNOPSIS Delete a Confluence page (delete:page). diff --git a/src/functions/public/Pages/Set-ConfluencePage.ps1 b/src/functions/public/Pages/Set-ConfluencePage.ps1 index ef68f7b..96d7725 100644 --- a/src/functions/public/Pages/Set-ConfluencePage.ps1 +++ b/src/functions/public/Pages/Set-ConfluencePage.ps1 @@ -1,4 +1,5 @@ -function Set-ConfluencePage { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Set-ConfluencePage { <# .SYNOPSIS Update a Confluence page (read:page and write:page). diff --git a/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 b/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 index b45a3a7..268d903 100644 --- a/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 +++ b/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluenceRestriction { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluenceRestriction { <# .SYNOPSIS Get the content restrictions on a page (read:content-details). diff --git a/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 b/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 index 1bb4568..38fa4f9 100644 --- a/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 +++ b/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluenceSiteInfo { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluenceSiteInfo { <# .SYNOPSIS Get basic information about the connected Confluence site (read:space or read:page). diff --git a/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 b/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 index da91b8c..b3f27ad 100644 --- a/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 +++ b/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluenceSpace { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluenceSpace { <# .SYNOPSIS Get a Confluence space (read:space). diff --git a/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 b/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 index 885ae91..0914c85 100644 --- a/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 +++ b/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluenceSpacePermission { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluenceSpacePermission { <# .SYNOPSIS List the permission assignments on a space (read:space). diff --git a/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 b/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 index 38ae6d7..8b35dd1 100644 --- a/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 +++ b/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluenceSpaceProperty { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluenceSpaceProperty { <# .SYNOPSIS Get space properties (read:space). diff --git a/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 b/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 index 3d66a5f..d3c2544 100644 --- a/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 +++ b/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 @@ -1,4 +1,5 @@ -function Get-ConfluenceCurrentUser { +#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. +function Get-ConfluenceCurrentUser { <# .SYNOPSIS Get the current (authenticated) user — Confluence's "me" endpoint (read:content-details). From cb00658d86d1cd59bb736e074c73c311cb3af4b9 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 02:15:28 +0200 Subject: [PATCH 07/21] Document the required Atlassian scopes for the service-account token Add SCOPES.md: the full granular Confluence scope set (22 scopes: 12 read, 6 write, 4 delete) the module's commands use, ready to paste when creating or rotating a scoped (ATSTT) API token used with Basic auth against the API gateway. Each scope maps to the commands that need it. Linked from the README. --- README.md | 2 ++ SCOPES.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 SCOPES.md diff --git a/README.md b/README.md index dcae3d4..39c7b9c 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Get-ConfluencePageChild -PageId $page.id See the [examples](examples) folder for more, including managing contexts and pages. +The service-account token must be granted the module's required Confluence scopes — see [SCOPES.md](SCOPES.md). + ## Documentation Documentation is published at [psmodule.io/Confluence](https://psmodule.io/Confluence/). diff --git a/SCOPES.md b/SCOPES.md new file mode 100644 index 0000000..bea7827 --- /dev/null +++ b/SCOPES.md @@ -0,0 +1,80 @@ +# Required Atlassian scopes + +`Confluence` authenticates with a **scoped** Atlassian API token (the `ATSTT…` +prefix) used as HTTP Basic authentication (`:`) +against the API-gateway host `https://api.atlassian.com/ex/confluence/`. + +When you create the token for the service account, select **granular** Confluence +scopes (not the classic `*-confluence-*` scopes). The list below is the full set +the module's commands use today — use it when generating or rotating a token. + +## Scope list + +```text +read:space:confluence +read:space.permission:confluence +read:space.property:confluence +read:page:confluence +read:blogpost:confluence +read:folder:confluence +read:attachment:confluence +read:comment:confluence +read:label:confluence +read:content.property:confluence +read:hierarchical-content:confluence +read:content-details:confluence +write:page:confluence +write:folder:confluence +write:attachment:confluence +write:comment:confluence +write:label:confluence +write:content.property:confluence +delete:page:confluence +delete:folder:confluence +delete:attachment:confluence +delete:comment:confluence +``` + +That is 22 scopes: 12 read, 6 write, 4 delete. + +## What each scope is for + +| Scope | Used by | +| --- | --- | +| `read:space:confluence` | `Get-ConfluenceSpace`, `Get-ConfluenceSiteInfo` | +| `read:space.permission:confluence` | `Get-ConfluenceSpacePermission` | +| `read:space.property:confluence` | `Get-ConfluenceSpaceProperty` | +| `read:page:confluence` | `Get-ConfluencePage`, `Get-ConfluencePageChild`, `Get-ConfluencePageVersion`, `Set-ConfluencePage` (reads before updating) | +| `read:blogpost:confluence` | `Get-ConfluenceBlogPost` | +| `read:folder:confluence` | `Get-ConfluenceFolder` | +| `read:attachment:confluence` | `Get-ConfluenceAttachment` | +| `read:comment:confluence` | `Get-ConfluenceComment` | +| `read:label:confluence` | `Get-ConfluenceLabel` | +| `read:content.property:confluence` | `Get-ConfluenceContentProperty` | +| `read:hierarchical-content:confluence` | `Get-ConfluenceDescendant` | +| `read:content-details:confluence` | `Get-ConfluenceRestriction`, `Get-ConfluenceCurrentUser`, `Add-ConfluenceAttachment` (upload) | +| `write:page:confluence` | `New-ConfluencePage`, `Set-ConfluencePage` | +| `write:folder:confluence` | `New-ConfluenceFolder` | +| `write:attachment:confluence` | `Add-ConfluenceAttachment` | +| `write:comment:confluence` | `Add-ConfluenceComment` | +| `write:label:confluence` | `Add-ConfluenceLabel`, `Remove-ConfluenceLabel` | +| `write:content.property:confluence` | `Set-ConfluenceContentProperty`, `Remove-ConfluenceContentProperty` | +| `delete:page:confluence` | `Remove-ConfluencePage` | +| `delete:folder:confluence` | `Remove-ConfluenceFolder` | +| `delete:attachment:confluence` | `Remove-ConfluenceAttachment` | +| `delete:comment:confluence` | `Remove-ConfluenceComment` | + +## Notes + +- Scopes are only an **API ceiling**. Effective access is still bounded by the + service account's Confluence **permissions** (global and per-space), so grant it + the matching permissions on the spaces you target. +- This set contains **no space-administration** scope: the token can read space + permissions and properties but cannot change space settings or permissions. +- A few commands reach v1 endpoints that Atlassian maps to these granular scopes: + label add/remove (`write:label:confluence`), attachment upload + (`write:attachment:confluence` plus `read:content-details:confluence`), page + restriction reads and the current user (`read:content-details:confluence`). +- Atlassian applies a soft cap of roughly 50 scopes per token; this set is well + under it, leaving room to grow. +- Reference: [Confluence OAuth 2.0 scopes](https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/). From 59542daeb9d93dda7fd470b4c0e8fbd7349d9982 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 04:41:47 +0200 Subject: [PATCH 08/21] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20[Maintenance]:=20Dec?= =?UTF-8?q?lare=20test=20secrets=20and=20variables=20via=20Process-PSModul?= =?UTF-8?q?e=20TestSecrets/TestVariables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces secrets: inherit with an explicit declaration of exactly what the module tests need: CONFLUENCE_API_TOKEN via TestSecrets (masked) and CONFLUENCE_API_BASE_URI / CONFLUENCE_USERNAME / CONFLUENCE_SPACE_KEY via TestVariables (not masked). Pinned to the Process-PSModule feature branch (PR #365) until it is released. --- .github/workflows/Process-PSModule.yml | 23 +++++++++++++++++++++-- tests/Confluence.Tests.ps1 | 7 ++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index 93c1ddd..6182a02 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -27,5 +27,24 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@ce64918acc96dda73eb78f827036b794bfa6fa1a # v5.5.7 - secrets: inherit + # Pinned to the TestSecrets/TestVariables feature branch of Process-PSModule (PR #365). + # Repin to the released tag once that change ships. + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@5ae2f4cfec688512e4889c014493dd581bfe7c38 # feat/52-test-secrets + secrets: + # PowerShell Gallery API key used to publish the module. + APIKey: ${{ secrets.APIKey }} + # Secrets the module's tests need, exposed as environment variables (masked). Only what the + # tests require is passed - secrets: inherit is intentionally not used. The integration tests + # are skipped when CONFLUENCE_API_TOKEN is not available (forks / PRs without access). + TestSecrets: >- + { + "CONFLUENCE_API_TOKEN": ${{ toJSON(secrets.CONFLUENCE_API_TOKEN) }} + } + with: + # Non-secret configuration the module's tests need, exposed as environment variables (not masked). + TestVariables: >- + { + "CONFLUENCE_API_BASE_URI": ${{ toJSON(vars.CONFLUENCE_API_BASE_URI) }}, + "CONFLUENCE_USERNAME": ${{ toJSON(vars.CONFLUENCE_USERNAME) }}, + "CONFLUENCE_SPACE_KEY": ${{ toJSON(vars.CONFLUENCE_SPACE_KEY) }} + } diff --git a/tests/Confluence.Tests.ps1 b/tests/Confluence.Tests.ps1 index 3e4d5be..c11768d 100644 --- a/tests/Confluence.Tests.ps1 +++ b/tests/Confluence.Tests.ps1 @@ -68,9 +68,10 @@ Describe 'Confluence' { } } - # Integration tests run only when live credentials are provided through the - # repository's GitHub Environment secrets. They are skipped locally and on - # pull requests where the secrets are not available. + # Integration tests run only when live credentials are provided. The calling workflow supplies + # them through Process-PSModule's TestSecrets (CONFLUENCE_API_TOKEN, masked) and TestVariables + # (CONFLUENCE_API_BASE_URI, CONFLUENCE_USERNAME, CONFLUENCE_SPACE_KEY), which are exposed as + # environment variables. They are skipped locally and wherever the API token is not available. Context 'Integration' -Skip:([string]::IsNullOrEmpty($env:CONFLUENCE_API_TOKEN)) { BeforeAll { $secureToken = ConvertTo-SecureString -String $env:CONFLUENCE_API_TOKEN -AsPlainText -Force From 71423fb748866b190ab3752ce75649af66ba352f Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 10:32:01 +0200 Subject: [PATCH 09/21] =?UTF-8?q?=F0=9F=93=96=20[Docs]:=20Record=20configu?= =?UTF-8?q?red=20token=20scopes=20and=20candidate=20commands=20in=20SCOPES?= =?UTF-8?q?.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates SCOPES.md to the 48 granular scopes currently configured (25 read, 14 write, 9 delete), grouped by verb. Keeps the current-command mapping, flags that read:blogpost:confluence is no longer configured (Get-ConfluenceBlogPost would 403), and adds an "Additional scopes available for new commands" table listing candidate cmdlets the extra scopes unlock (custom content, databases, groups, users, restriction writes, analytics, comment updates, permission checks, configuration, space settings, embeds, whiteboard delete). --- SCOPES.md | 123 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 98 insertions(+), 25 deletions(-) diff --git a/SCOPES.md b/SCOPES.md index bea7827..6ca6829 100644 --- a/SCOPES.md +++ b/SCOPES.md @@ -5,39 +5,80 @@ prefix) used as HTTP Basic authentication (`:`) against the API-gateway host `https://api.atlassian.com/ex/confluence/`. When you create the token for the service account, select **granular** Confluence -scopes (not the classic `*-confluence-*` scopes). The list below is the full set -the module's commands use today — use it when generating or rotating a token. +scopes (not the classic `*-confluence-*` scopes). Use this document when +generating or rotating a token. -## Scope list +## Configured scopes + +The token is currently configured with **48** granular Confluence scopes +(**25 read**, **14 write**, **9 delete**). This is broader than the set the +module's commands use today (see the tables below), leaving headroom for new +commands. + +### Read (25) ```text -read:space:confluence -read:space.permission:confluence -read:space.property:confluence -read:page:confluence -read:blogpost:confluence -read:folder:confluence +read:analytics.content:confluence read:attachment:confluence read:comment:confluence -read:label:confluence +read:configuration:confluence +read:content:confluence +read:content-details:confluence +read:content.metadata:confluence +read:content.permission:confluence read:content.property:confluence +read:content.restriction:confluence +read:custom-content:confluence +read:database:confluence +read:email-address:confluence +read:folder:confluence +read:group:confluence read:hierarchical-content:confluence -read:content-details:confluence -write:page:confluence -write:folder:confluence +read:label:confluence +read:page:confluence +read:permission:confluence +read:space:confluence +read:space-details:confluence +read:space.permission:confluence +read:space.property:confluence +read:space.setting:confluence +read:user:confluence +``` + +### Write (14) + +```text write:attachment:confluence +write:audit-log:confluence write:comment:confluence -write:label:confluence +write:configuration:confluence +write:content:confluence write:content.property:confluence -delete:page:confluence -delete:folder:confluence +write:content.restriction:confluence +write:custom-content:confluence +write:database:confluence +write:embed:confluence +write:folder:confluence +write:group:confluence +write:label:confluence +write:page:confluence +``` + +### Delete (9) + +```text delete:attachment:confluence delete:comment:confluence +delete:content:confluence +delete:custom-content:confluence +delete:database:confluence +delete:embed:confluence +delete:folder:confluence +delete:page:confluence +delete:whiteboard:confluence ``` -That is 22 scopes: 12 read, 6 write, 4 delete. - -## What each scope is for +## Scopes used by the current commands | Scope | Used by | | --- | --- | @@ -45,7 +86,6 @@ That is 22 scopes: 12 read, 6 write, 4 delete. | `read:space.permission:confluence` | `Get-ConfluenceSpacePermission` | | `read:space.property:confluence` | `Get-ConfluenceSpaceProperty` | | `read:page:confluence` | `Get-ConfluencePage`, `Get-ConfluencePageChild`, `Get-ConfluencePageVersion`, `Set-ConfluencePage` (reads before updating) | -| `read:blogpost:confluence` | `Get-ConfluenceBlogPost` | | `read:folder:confluence` | `Get-ConfluenceFolder` | | `read:attachment:confluence` | `Get-ConfluenceAttachment` | | `read:comment:confluence` | `Get-ConfluenceComment` | @@ -64,17 +104,50 @@ That is 22 scopes: 12 read, 6 write, 4 delete. | `delete:attachment:confluence` | `Remove-ConfluenceAttachment` | | `delete:comment:confluence` | `Remove-ConfluenceComment` | +> Important: `Get-ConfluenceBlogPost` needs `read:blogpost:confluence`, which is +> **not** in the configured set. Add that scope (or retire the command) — the +> `/blogposts` endpoints will otherwise return 403. There is also no +> `write:blogpost` / `delete:blogpost`, so blog posts cannot be created or removed. + +## Additional scopes available for new commands + +These scopes are configured but not yet backed by a command. Each row lists the +candidate cmdlets it would enable (verbs in `{}` share one scope family): + +| Scope(s) | Candidate commands | +| --- | --- | +| `{read,write,delete}:custom-content:confluence` | Full custom-content CRUD: `Get-`, `New-`, `Set-`, `Remove-ConfluenceCustomContent` | +| `{read,write,delete}:database:confluence` | Database CRUD: `Get-`, `New-`, `Set-`, `Remove-ConfluenceDatabase` | +| `read:group:confluence` + `write:group:confluence` | `Get-ConfluenceGroup`, `Get-ConfluenceGroupMember`, `New-ConfluenceGroup`, `Add-`/`Remove-ConfluenceGroupMember` | +| `read:user:confluence` + `read:email-address:confluence` | `Get-ConfluenceUser` (by accountId, incl. email); simplifies `Get-ConfluenceCurrentUser` | +| `{read,write}:content.restriction:confluence` | `Add-`, `Set-`, `Remove-ConfluenceRestriction` (complements the existing `Get-ConfluenceRestriction`) | +| `read:analytics.content:confluence` | `Get-ConfluenceContentAnalytics` (page/blog view + viewer counts) | +| `write:comment:confluence` | `Set-ConfluenceComment` (update an existing comment) | +| `read:permission:confluence` + `read:content.permission:confluence` | `Test-ConfluenceContentPermission` / `Get-ConfluenceOperationCheck` | +| `{read,write}:configuration:confluence` | `Get-`/`Set-ConfluenceConfiguration` (look-and-feel / system settings; needs admin) | +| `read:space.setting:confluence` + `read:space-details:confluence` | `Get-ConfluenceSpaceSetting`; richer `Get-ConfluenceSpace` details | +| `{read,write,delete}:content:confluence`, `read:content.metadata:confluence` | Generic/classic content search & operations, e.g. `Find-ConfluenceContent` (CQL) | +| `{write,delete}:embed:confluence` | `New-`/`Remove-ConfluenceEmbed` (Smart Links) — no `read:embed`, so read-back is unavailable | +| `delete:whiteboard:confluence` | `Remove-ConfluenceWhiteboard` only — no `read:`/`write:whiteboard`, so create/read is unavailable | +| `write:audit-log:confluence` | Audit-log export/write (admin) — no `read:audit-log`, so reading records is unavailable | + ## Notes - Scopes are only an **API ceiling**. Effective access is still bounded by the service account's Confluence **permissions** (global and per-space), so grant it - the matching permissions on the spaces you target. -- This set contains **no space-administration** scope: the token can read space - permissions and properties but cannot change space settings or permissions. + the matching permissions on the spaces you target. Admin APIs (`configuration`, + `audit-log`, `group`) additionally need org/site admin rights. +- **Asymmetric sets** worth noting: `whiteboard` is delete-only; `embed` has + write/delete but no read; `audit-log` is write-only. Commands for those can only + cover the granted verbs. +- This set has **no space-administration write** scope (`write:space` / + `delete:space`): the token can read space settings, permissions and properties + but cannot create, update or delete spaces. - A few commands reach v1 endpoints that Atlassian maps to these granular scopes: label add/remove (`write:label:confluence`), attachment upload (`write:attachment:confluence` plus `read:content-details:confluence`), page restriction reads and the current user (`read:content-details:confluence`). -- Atlassian applies a soft cap of roughly 50 scopes per token; this set is well - under it, leaving room to grow. +- Atlassian applies a soft cap of roughly **50 scopes** per token; at **48** this + set is near the cap, so adding the missing read scopes (blogpost, whiteboard, + embed) would leave little room and may require dropping unused ones. - Reference: [Confluence OAuth 2.0 scopes](https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/). From 56b279ab120c487ffde34415b00152e346e98d46 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 10:44:14 +0200 Subject: [PATCH 10/21] =?UTF-8?q?=F0=9F=9A=80=20[Feature]:=20Add=20Convert?= =?UTF-8?q?To-ConfluenceCloudId=20to=20resolve=20a=20site=20to=20its=20clo?= =?UTF-8?q?ud=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New public function resolves an Atlassian Confluence Cloud site to its cloud ID via the public /_edge/tenant_info endpoint, so callers can supply a site name or URL (bare subdomain, host, or full URL; pipeline supported) instead of the cloud ID. Unauthenticated; no scope required. Also reverts an accidental Context->Get-Context edit in the test file and registers the command in the surface test. --- .../Site/ConvertTo-ConfluenceCloudId.ps1 | 90 +++++++++++++++++++ tests/Confluence.Tests.ps1 | 1 + 2 files changed, 91 insertions(+) create mode 100644 src/functions/public/Site/ConvertTo-ConfluenceCloudId.ps1 diff --git a/src/functions/public/Site/ConvertTo-ConfluenceCloudId.ps1 b/src/functions/public/Site/ConvertTo-ConfluenceCloudId.ps1 new file mode 100644 index 0000000..952ee61 --- /dev/null +++ b/src/functions/public/Site/ConvertTo-ConfluenceCloudId.ps1 @@ -0,0 +1,90 @@ +#SkipTest:FunctionTest:Resolves the cloud ID from the public tenant_info endpoint; covered by integration tests. +function ConvertTo-ConfluenceCloudId { + <# + .SYNOPSIS + Resolve a Confluence Cloud site to its cloud ID. + + .DESCRIPTION + Looks up the cloud ID for an Atlassian Confluence Cloud site from the + public `/_edge/tenant_info` endpoint, so a caller can supply a site name or + URL instead of the cloud ID. Accepts a bare subdomain (`myorg`), a host + (`myorg.atlassian.net`), or any URL on the site + (`https://myorg.atlassian.net/wiki/...`). No authentication is required. + + .EXAMPLE + ```powershell + ConvertTo-ConfluenceCloudId -Site 'msxorg' + ``` + + Returns the cloud ID for `https://msxorg.atlassian.net`. + + .EXAMPLE + ```powershell + 'https://msxorg.atlassian.net/wiki/spaces/DOCS' | ConvertTo-ConfluenceCloudId + ``` + + Resolves the cloud ID from a full site URL supplied through the pipeline. + + .EXAMPLE + ```powershell + $cloudId = ConvertTo-ConfluenceCloudId -Site 'msxorg' + Connect-Confluence -ApiBaseUri "https://api.atlassian.com/ex/confluence/$cloudId" -Username $user -Token $token + ``` + + Uses the resolved cloud ID to build the API-gateway base URI for `Connect-Confluence`, + so the caller only needs the site name. + + .OUTPUTS + System.String + + .LINK + https://psmodule.io/Confluence/Functions/Site/ConvertTo-ConfluenceCloudId/ + + .LINK + https://developer.atlassian.com/cloud/confluence/rest/v2/intro/#about + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The site to resolve: a subdomain (`myorg`), a host (`myorg.atlassian.net`), + # or any URL on the site (`https://myorg.atlassian.net/...`). + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('Url', 'Uri', 'Name')] + [string]$Site + ) + + process { + $trimmed = $Site.Trim() + if ([string]::IsNullOrWhiteSpace($trimmed)) { + throw 'The -Site value cannot be empty.' + } + + # Derive the host whether the caller passed a bare subdomain, a host, or a URL. + $siteHost = if ($trimmed -match '^[A-Za-z][A-Za-z0-9+.-]*://') { + ([uri]$trimmed).Host + } elseif ($trimmed -match '[/.]') { + ([uri]"https://$trimmed").Host + } else { + "$trimmed.atlassian.net" + } + + if ([string]::IsNullOrWhiteSpace($siteHost)) { + throw "Could not determine the site host from '$Site'." + } + + $tenantInfoUri = "https://$siteHost/_edge/tenant_info" + Write-Verbose "Resolving cloud ID from [$tenantInfoUri]." + + try { + $tenantInfo = Invoke-RestMethod -Uri $tenantInfoUri -Method Get -ErrorAction Stop + } catch { + throw "Failed to resolve the cloud ID for '$siteHost' from [$tenantInfoUri]: $($_.Exception.Message)" + } + + if ([string]::IsNullOrWhiteSpace($tenantInfo.cloudId)) { + throw "The endpoint [$tenantInfoUri] did not return a cloud ID for '$siteHost'." + } + + $tenantInfo.cloudId + } +} diff --git a/tests/Confluence.Tests.ps1 b/tests/Confluence.Tests.ps1 index c11768d..359f777 100644 --- a/tests/Confluence.Tests.ps1 +++ b/tests/Confluence.Tests.ps1 @@ -39,6 +39,7 @@ Describe 'Confluence' { 'Invoke-ConfluenceRestMethod' 'Get-ConfluenceSpace' 'Get-ConfluenceSiteInfo' + 'ConvertTo-ConfluenceCloudId' 'New-ConfluencePage' 'Get-ConfluencePage' 'Set-ConfluencePage' From 4e867fcda8078272a378deba8789d81cb1fb6a9d Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 11:03:03 +0200 Subject: [PATCH 11/21] =?UTF-8?q?=F0=9F=9A=80=20[Feature]:=20Connect=20by?= =?UTF-8?q?=20site=20name;=20add=20accessible-resources=20and=20v1/v2=20ma?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect-Confluence now takes -Site (name/host/URL, resolved to a cloud ID via the public tenant_info endpoint) or -CloudId (faster, direct); it builds the api.atlassian.com/ex/confluence/ gateway base internally. Adds Get-ConfluenceAccessibleResource (lists sites/cloudIds/scopes a bearer token can reach) and an internal v1/v2 API-path map (v1 -> /wiki/rest/api, v2 -> /wiki/api/v2) usable via Invoke-ConfluenceRestMethod -ApiVersion. Integration test connects with -Site from CONFLUENCE_SITE. --- .github/workflows/Process-PSModule.yml | 2 +- .../API/Invoke-ConfluenceRestMethod.ps1 | 12 ++++ .../public/Auth/Connect-Confluence.ps1 | 39 +++++++++--- .../Auth/Get-ConfluenceAccessibleResource.ps1 | 62 +++++++++++++++++++ src/variables/private/Confluence.ps1 | 8 +++ tests/Confluence.Tests.ps1 | 15 ++--- 6 files changed, 122 insertions(+), 16 deletions(-) create mode 100644 src/functions/public/Auth/Get-ConfluenceAccessibleResource.ps1 diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index 6182a02..bf6abe1 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -44,7 +44,7 @@ jobs: # Non-secret configuration the module's tests need, exposed as environment variables (not masked). TestVariables: >- { - "CONFLUENCE_API_BASE_URI": ${{ toJSON(vars.CONFLUENCE_API_BASE_URI) }}, + "CONFLUENCE_SITE": ${{ toJSON(vars.CONFLUENCE_SITE) }}, "CONFLUENCE_USERNAME": ${{ toJSON(vars.CONFLUENCE_USERNAME) }}, "CONFLUENCE_SPACE_KEY": ${{ toJSON(vars.CONFLUENCE_SPACE_KEY) }} } diff --git a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 index 8139ac8..3e736f3 100644 --- a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 +++ b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 @@ -40,9 +40,16 @@ function Invoke-ConfluenceRestMethod { param( # The API path beginning with '/wiki/', e.g. '/wiki/api/v2/pages'. # A full absolute URL is also accepted (used when following pagination links). + # When -ApiVersion is supplied this is treated as a path relative to that + # API family's root (e.g. -ApiVersion v2 -ApiEndpoint 'spaces'). [Parameter(Mandatory)] [string]$ApiEndpoint, + # Optional API family. Uses the internal map to prepend the version prefix + # (v1 -> /wiki/rest/api, v2 -> /wiki/api/v2) to a relative -ApiEndpoint. + [ValidateSet('v1', 'v2')] + [string]$ApiVersion, + # The HTTP method. Defaults to GET. [ValidateSet('GET', 'POST', 'PUT', 'DELETE')] [string]$Method = 'GET', @@ -67,6 +74,11 @@ function Invoke-ConfluenceRestMethod { throw "The -All switch follows pagination and is only valid for GET requests, not $Method." } + # Resolve a relative endpoint against the internal v1/v2 map when a version is given. + if ($ApiVersion -and $ApiEndpoint -notmatch '^https?://') { + $ApiEndpoint = '{0}/{1}' -f $script:Confluence.ApiPaths[$ApiVersion], $ApiEndpoint.TrimStart('/') + } + $resolved = Resolve-ConfluenceContext -Context $Context $token = Resolve-ConfluenceToken -Token $resolved.Token $pair = '{0}:{1}' -f $resolved.Username, $token diff --git a/src/functions/public/Auth/Connect-Confluence.ps1 b/src/functions/public/Auth/Connect-Confluence.ps1 index def9f72..35d13a9 100644 --- a/src/functions/public/Auth/Connect-Confluence.ps1 +++ b/src/functions/public/Auth/Connect-Confluence.ps1 @@ -7,16 +7,26 @@ function Connect-Confluence { .DESCRIPTION Validates the supplied credentials with a lightweight authenticated call, stores them as a named context (the token is kept as a SecureString), and - records the context as the module default. A token that authenticates but - lacks the read:space scope still connects (with a warning). + records the context as the module default. Supply the site with -Site (a + name such as 'msxorg', a host, or a URL) and the cloud ID is resolved + automatically; or pass a known -CloudId directly to skip the lookup. A + token that authenticates but lacks the read:space scope still connects + (with a warning). .EXAMPLE ```powershell $token = Read-Host -AsSecureString - Connect-Confluence -ApiBaseUri $uri -Username $user -Token $token -SpaceKey 'DOCS' + Connect-Confluence -Site 'msxorg' -Username $user -Token $token -SpaceKey 'DOCS' ``` - Connects with a scoped token and stores the profile with 'DOCS' as the default space. + Resolves the cloud ID for msxorg.atlassian.net, connects, and stores 'DOCS' as the default space. + + .EXAMPLE + ```powershell + Connect-Confluence -CloudId 'fff64f40-36b7-4578-92be-b9d9b6b17658' -Username $user -Token $token + ``` + + Connects directly with a known cloud ID, skipping the site lookup. .LINK https://psmodule.io/Confluence/Functions/Auth/Connect-Confluence/ @@ -27,12 +37,18 @@ function Connect-Confluence { .LINK https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/ #> - [CmdletBinding(SupportsShouldProcess)] + [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Site')] [OutputType([pscustomobject])] param( - # The Confluence API-gateway base URI, for example `https://api.atlassian.com/ex/confluence/`. - [Parameter(Mandatory)] - [string]$ApiBaseUri, + # The Confluence Cloud site: a name (`msxorg`), a host (`msxorg.atlassian.net`), + # or any URL on the site. The cloud ID is resolved automatically. + [Parameter(Mandatory, ParameterSetName = 'Site')] + [string]$Site, + + # The Confluence Cloud ID - a faster alternative to -Site that skips the lookup. + # Find it with `ConvertTo-ConfluenceCloudId` or `Get-ConfluenceAccessibleResource`. + [Parameter(Mandatory, ParameterSetName = 'CloudId')] + [string]$CloudId, # The service-account user (email) used for HTTP Basic authentication. [Parameter(Mandatory)] @@ -52,6 +68,13 @@ function Connect-Confluence { [switch]$PassThru ) + if ($PSCmdlet.ParameterSetName -eq 'Site') { + # Resolve a site name/host/URL to its cloud ID via the public tenant_info endpoint. + $CloudId = ConvertTo-ConfluenceCloudId -Site $Site + } + # The scoped-token gateway base is always api.atlassian.com/ex/confluence/. + $ApiBaseUri = 'https://api.atlassian.com/ex/confluence/{0}' -f $CloudId + if ([string]::IsNullOrEmpty($Name)) { # api.atlassian.com is shared by every Confluence Cloud site, so the host # alone is not unique. Include the trailing base-URI path segment (the diff --git a/src/functions/public/Auth/Get-ConfluenceAccessibleResource.ps1 b/src/functions/public/Auth/Get-ConfluenceAccessibleResource.ps1 new file mode 100644 index 0000000..8f6e68e --- /dev/null +++ b/src/functions/public/Auth/Get-ConfluenceAccessibleResource.ps1 @@ -0,0 +1,62 @@ +#SkipTest:FunctionTest:Calls the public accessible-resources endpoint with a bearer token; covered by integration tests. +function Get-ConfluenceAccessibleResource { + <# + .SYNOPSIS + List the Atlassian sites (resources) a token can reach. + + .DESCRIPTION + Calls `https://api.atlassian.com/oauth/token/accessible-resources` with the + token as a bearer credential and returns one object per accessible site. + Each result exposes the site `id` (which is the cloud ID used in the + API-gateway base URI), the site `url`, the `name`, and the `scopes` granted + to the token for that site. Use it to discover the cloud ID and confirm the + granted scopes before connecting. + + .EXAMPLE + ```powershell + $token = Read-Host -AsSecureString + Get-ConfluenceAccessibleResource -Token $token + ``` + + Lists every site the token can reach, with the cloud ID (`id`), URL and granted scopes. + + .EXAMPLE + ```powershell + (Get-ConfluenceAccessibleResource -Token $token | Where-Object url -match 'msxorg').id + ``` + + Returns the cloud ID for the msxorg site from the token's accessible resources. + + .OUTPUTS + System.Management.Automation.PSObject + + .LINK + https://psmodule.io/Confluence/Functions/Auth/Get-ConfluenceAccessibleResource/ + + .LINK + https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/#3--make-calls-to-the-api-using-the-access-token + #> + [CmdletBinding()] + [OutputType([object])] + param( + # The scoped Atlassian API token as a SecureString. + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [securestring]$Token + ) + + process { + $bearer = Resolve-ConfluenceToken -Token $Token + $headers = @{ + Authorization = "Bearer $bearer" + Accept = 'application/json' + } + $uri = 'https://api.atlassian.com/oauth/token/accessible-resources' + Write-Verbose "Listing accessible resources from [$uri]." + + try { + Invoke-RestMethod -Uri $uri -Headers $headers -Method Get -ErrorAction Stop + } catch { + throw "Failed to list accessible resources from [$uri]: $($_.Exception.Message)" + } + } +} diff --git a/src/variables/private/Confluence.ps1 b/src/variables/private/Confluence.ps1 index 73685d6..fc10b32 100644 --- a/src/variables/private/Confluence.ps1 +++ b/src/variables/private/Confluence.ps1 @@ -1,5 +1,13 @@ $script:Confluence = [pscustomobject]@{ ContextVault = 'PSModule.Confluence' + # The API-gateway base is https://api.atlassian.com/ex/confluence/. These are the two + # Confluence API families that hang off it; the map keeps the version prefixes in one place. + # v2 -> /wiki/api/v2/... (e.g. /wiki/api/v2/spaces) + # v1 -> /wiki/rest/api/... (e.g. /wiki/rest/api/content) + ApiPaths = @{ + v1 = '/wiki/rest/api' + v2 = '/wiki/api/v2' + } DefaultConfig = @{ ID = 'Module' DefaultContext = '' diff --git a/tests/Confluence.Tests.ps1 b/tests/Confluence.Tests.ps1 index 359f777..a8fc3ef 100644 --- a/tests/Confluence.Tests.ps1 +++ b/tests/Confluence.Tests.ps1 @@ -40,6 +40,7 @@ Describe 'Confluence' { 'Get-ConfluenceSpace' 'Get-ConfluenceSiteInfo' 'ConvertTo-ConfluenceCloudId' + 'Get-ConfluenceAccessibleResource' 'New-ConfluencePage' 'Get-ConfluencePage' 'Set-ConfluencePage' @@ -71,17 +72,17 @@ Describe 'Confluence' { # Integration tests run only when live credentials are provided. The calling workflow supplies # them through Process-PSModule's TestSecrets (CONFLUENCE_API_TOKEN, masked) and TestVariables - # (CONFLUENCE_API_BASE_URI, CONFLUENCE_USERNAME, CONFLUENCE_SPACE_KEY), which are exposed as - # environment variables. They are skipped locally and wherever the API token is not available. + # (CONFLUENCE_SITE, CONFLUENCE_USERNAME, CONFLUENCE_SPACE_KEY), which are exposed as environment + # variables. They are skipped locally and wherever the API token is not available. Context 'Integration' -Skip:([string]::IsNullOrEmpty($env:CONFLUENCE_API_TOKEN)) { BeforeAll { $secureToken = ConvertTo-SecureString -String $env:CONFLUENCE_API_TOKEN -AsPlainText -Force $connectParams = @{ - ApiBaseUri = $env:CONFLUENCE_API_BASE_URI - Username = $env:CONFLUENCE_USERNAME - Token = $secureToken - SpaceKey = $env:CONFLUENCE_SPACE_KEY - Name = 'ci' + Site = $env:CONFLUENCE_SITE + Username = $env:CONFLUENCE_USERNAME + Token = $secureToken + SpaceKey = $env:CONFLUENCE_SPACE_KEY + Name = 'ci' } Connect-Confluence @connectParams } From 4fcca561cdfb2acb78eb2f79799013d03ce0517d Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 11:31:54 +0200 Subject: [PATCH 12/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[Maintenance]:=20Ren?= =?UTF-8?q?ame=20ConvertTo-ConfluenceCloudId=20to=20Get-ConfluenceCloudId;?= =?UTF-8?q?=20record=20token=20scopes=20on=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename ConvertTo-ConfluenceCloudId to Get-ConfluenceCloudId: the function performs a GET against /_edge/tenant_info, so a Get- verb is accurate. Help, examples, the Connect-Confluence reference, and the surface test are updated. - Connect-Confluence now collects the token's scopes from the accessible-resources endpoint and stores them on the context (Scopes), so callers can see what the token can do. Collection is best-effort and never fails the connection. --- .../public/Auth/Connect-Confluence.ps1 | 18 +++++++++++++-- ...eCloudId.ps1 => Get-ConfluenceCloudId.ps1} | 22 +++++++++---------- tests/Confluence.Tests.ps1 | 2 +- 3 files changed, 27 insertions(+), 15 deletions(-) rename src/functions/public/Site/{ConvertTo-ConfluenceCloudId.ps1 => Get-ConfluenceCloudId.ps1} (73%) diff --git a/src/functions/public/Auth/Connect-Confluence.ps1 b/src/functions/public/Auth/Connect-Confluence.ps1 index 35d13a9..db7cb70 100644 --- a/src/functions/public/Auth/Connect-Confluence.ps1 +++ b/src/functions/public/Auth/Connect-Confluence.ps1 @@ -46,7 +46,7 @@ function Connect-Confluence { [string]$Site, # The Confluence Cloud ID - a faster alternative to -Site that skips the lookup. - # Find it with `ConvertTo-ConfluenceCloudId` or `Get-ConfluenceAccessibleResource`. + # Find it with `Get-ConfluenceCloudId` or `Get-ConfluenceAccessibleResource`. [Parameter(Mandatory, ParameterSetName = 'CloudId')] [string]$CloudId, @@ -70,7 +70,7 @@ function Connect-Confluence { if ($PSCmdlet.ParameterSetName -eq 'Site') { # Resolve a site name/host/URL to its cloud ID via the public tenant_info endpoint. - $CloudId = ConvertTo-ConfluenceCloudId -Site $Site + $CloudId = Get-ConfluenceCloudId -Site $Site } # The scoped-token gateway base is always api.atlassian.com/ex/confluence/. $ApiBaseUri = 'https://api.atlassian.com/ex/confluence/{0}' -f $CloudId @@ -100,6 +100,7 @@ function Connect-Confluence { Username = $Username Token = $Token SpaceKey = $SpaceKey + Scopes = @() } if (-not $PSCmdlet.ShouldProcess($Name, 'Connect to Confluence and store credential context')) { @@ -118,6 +119,19 @@ function Connect-Confluence { Write-Warning "Token authenticated but lacks read:space; space listing is unavailable for context '$Name'." } + # Best-effort: record the scopes the token holds for this site (from the + # accessible-resources endpoint) so the stored context reflects what we can do. + # Never fail the connection if scope discovery is unavailable for this token. + try { + $resource = Get-ConfluenceAccessibleResource -Token $Token | Where-Object { $_.id -eq $CloudId } | Select-Object -First 1 + if ($resource -and $resource.scopes) { + $context['Scopes'] = @($resource.scopes) + Write-Verbose "Recorded $($context['Scopes'].Count) scope(s) for context '$Name'." + } + } catch { + Write-Verbose "Could not resolve token scopes from accessible-resources: $($_.Exception.Message)" + } + $stored = Set-Context -ID $Name -Context $context -Vault $script:Confluence.ContextVault -PassThru Set-ConfluenceConfig -Name 'DefaultContext' -Value $Name diff --git a/src/functions/public/Site/ConvertTo-ConfluenceCloudId.ps1 b/src/functions/public/Site/Get-ConfluenceCloudId.ps1 similarity index 73% rename from src/functions/public/Site/ConvertTo-ConfluenceCloudId.ps1 rename to src/functions/public/Site/Get-ConfluenceCloudId.ps1 index 952ee61..2f1c177 100644 --- a/src/functions/public/Site/ConvertTo-ConfluenceCloudId.ps1 +++ b/src/functions/public/Site/Get-ConfluenceCloudId.ps1 @@ -1,44 +1,42 @@ #SkipTest:FunctionTest:Resolves the cloud ID from the public tenant_info endpoint; covered by integration tests. -function ConvertTo-ConfluenceCloudId { +function Get-ConfluenceCloudId { <# .SYNOPSIS - Resolve a Confluence Cloud site to its cloud ID. + Get the cloud ID for a Confluence Cloud site. .DESCRIPTION Looks up the cloud ID for an Atlassian Confluence Cloud site from the - public `/_edge/tenant_info` endpoint, so a caller can supply a site name or - URL instead of the cloud ID. Accepts a bare subdomain (`myorg`), a host - (`myorg.atlassian.net`), or any URL on the site + public `/_edge/tenant_info` endpoint (a GET request), so a caller can supply + a site name or URL instead of the cloud ID. Accepts a bare subdomain + (`myorg`), a host (`myorg.atlassian.net`), or any URL on the site (`https://myorg.atlassian.net/wiki/...`). No authentication is required. .EXAMPLE ```powershell - ConvertTo-ConfluenceCloudId -Site 'msxorg' + Get-ConfluenceCloudId -Site 'msxorg' ``` Returns the cloud ID for `https://msxorg.atlassian.net`. .EXAMPLE ```powershell - 'https://msxorg.atlassian.net/wiki/spaces/DOCS' | ConvertTo-ConfluenceCloudId + 'https://msxorg.atlassian.net/wiki/spaces/DOCS' | Get-ConfluenceCloudId ``` Resolves the cloud ID from a full site URL supplied through the pipeline. .EXAMPLE ```powershell - $cloudId = ConvertTo-ConfluenceCloudId -Site 'msxorg' - Connect-Confluence -ApiBaseUri "https://api.atlassian.com/ex/confluence/$cloudId" -Username $user -Token $token + Connect-Confluence -CloudId (Get-ConfluenceCloudId -Site 'msxorg') -Username $user -Token $token ``` - Uses the resolved cloud ID to build the API-gateway base URI for `Connect-Confluence`, - so the caller only needs the site name. + Uses the resolved cloud ID to connect, so the caller only needs the site name. .OUTPUTS System.String .LINK - https://psmodule.io/Confluence/Functions/Site/ConvertTo-ConfluenceCloudId/ + https://psmodule.io/Confluence/Functions/Site/Get-ConfluenceCloudId/ .LINK https://developer.atlassian.com/cloud/confluence/rest/v2/intro/#about diff --git a/tests/Confluence.Tests.ps1 b/tests/Confluence.Tests.ps1 index a8fc3ef..75d240c 100644 --- a/tests/Confluence.Tests.ps1 +++ b/tests/Confluence.Tests.ps1 @@ -39,7 +39,7 @@ Describe 'Confluence' { 'Invoke-ConfluenceRestMethod' 'Get-ConfluenceSpace' 'Get-ConfluenceSiteInfo' - 'ConvertTo-ConfluenceCloudId' + 'Get-ConfluenceCloudId' 'Get-ConfluenceAccessibleResource' 'New-ConfluencePage' 'Get-ConfluencePage' From 9f23e48866287b29f8d66eb31834360d84030597 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 11:31:54 +0200 Subject: [PATCH 13/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[Maintenance]:=20Pas?= =?UTF-8?q?s=20a=20single=20TestData=20secret=20to=20Process-PSModule=20(C?= =?UTF-8?q?odeQL-clean)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt the combined TestData secret from Process-PSModule: one object with "secrets" (masked) and "variables" (not masked) instead of separate TestSecrets and TestVariables. Reference the token with the direct "${{ secrets.X }}" form to resolve the CodeQL "excessive secrets exposure" alert, and keep the blob on one folded line so GitHub registers a single mask. Repin to the framework branch head. --- .github/workflows/Process-PSModule.yml | 28 +++++++++++--------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index bf6abe1..36aa723 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -27,24 +27,20 @@ permissions: jobs: Process-PSModule: - # Pinned to the TestSecrets/TestVariables feature branch of Process-PSModule (PR #365). + # Pinned to the TestData feature branch of Process-PSModule (PR #365). # Repin to the released tag once that change ships. - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@5ae2f4cfec688512e4889c014493dd581bfe7c38 # feat/52-test-secrets + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@ea019351821a74ae1528a4472198c84c91dd3059 # feat/52-test-secrets secrets: # PowerShell Gallery API key used to publish the module. APIKey: ${{ secrets.APIKey }} - # Secrets the module's tests need, exposed as environment variables (masked). Only what the - # tests require is passed - secrets: inherit is intentionally not used. The integration tests - # are skipped when CONFLUENCE_API_TOKEN is not available (forks / PRs without access). - TestSecrets: >- - { - "CONFLUENCE_API_TOKEN": ${{ toJSON(secrets.CONFLUENCE_API_TOKEN) }} - } - with: - # Non-secret configuration the module's tests need, exposed as environment variables (not masked). - TestVariables: >- - { - "CONFLUENCE_SITE": ${{ toJSON(vars.CONFLUENCE_SITE) }}, + # All data the module's tests need, in one object: a "secrets" map (masked) and a "variables" + # map (not masked), each entry exposed as an environment variable. Only what the tests require + # is passed - secrets: inherit is intentionally not used. Integration tests skip when + # CONFLUENCE_API_TOKEN is unavailable (forks / PRs without access). Secrets use the direct + # "${{ secrets.X }}" form (CodeQL-clean) and the whole blob is one folded line so GitHub + # registers a single mask. + TestData: >- + { "secrets": { "CONFLUENCE_API_TOKEN": "${{ secrets.CONFLUENCE_API_TOKEN }}" }, + "variables": { "CONFLUENCE_SITE": ${{ toJSON(vars.CONFLUENCE_SITE) }}, "CONFLUENCE_USERNAME": ${{ toJSON(vars.CONFLUENCE_USERNAME) }}, - "CONFLUENCE_SPACE_KEY": ${{ toJSON(vars.CONFLUENCE_SPACE_KEY) }} - } + "CONFLUENCE_SPACE_KEY": ${{ toJSON(vars.CONFLUENCE_SPACE_KEY) }} } } From 4cd4886e2b17797d1f36f546e9b83376d12f664a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 12:02:17 +0200 Subject: [PATCH 14/21] =?UTF-8?q?=F0=9F=93=96=20[Docs]:=20Fix=20Connect-Co?= =?UTF-8?q?nfluence=20examples=20to=20use=20-Site/-CloudId=20and=20align?= =?UTF-8?q?=20blog=20post=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- examples/Connecting.ps1 | 6 ++++-- src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 39c7b9c..50b5b8e 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Connect with a scoped Atlassian API token, then call the resource commands. The ```powershell # Connect and store a reusable, named credential profile $token = Read-Host -AsSecureString # a scoped Atlassian API token -Connect-Confluence -ApiBaseUri 'https://api.atlassian.com/ex/confluence/' -Username 'you@example.com' -Token $token -SpaceKey 'DOCS' +Connect-Confluence -Site 'yoursite' -Username 'you@example.com' -Token $token -SpaceKey 'DOCS' # Work with content $space = Get-ConfluenceSpace -Key 'DOCS' diff --git a/examples/Connecting.ps1 b/examples/Connecting.ps1 index 808df53..71d1e3c 100644 --- a/examples/Connecting.ps1 +++ b/examples/Connecting.ps1 @@ -16,11 +16,13 @@ Import-Module -Name 'Confluence' ### # Connect with a scoped Atlassian API token and store a reusable default profile. +# -Site takes a subdomain, host, or any URL on the site and resolves the cloud ID for you. $token = Read-Host -AsSecureString # a scoped Atlassian API token -Connect-Confluence -ApiBaseUri 'https://api.atlassian.com/ex/confluence/' -Username 'you@example.com' -Token $token -SpaceKey 'DOCS' +Connect-Confluence -Site 'yoursite' -Username 'you@example.com' -Token $token -SpaceKey 'DOCS' # Store several sites/accounts under explicit names and select per call. -Connect-Confluence -ApiBaseUri 'https://api.atlassian.com/ex/confluence/' -Username 'you@example.com' -Token $token -Name 'sandbox' +# If you already know the cloud ID, pass it directly with -CloudId to skip the lookup. +Connect-Confluence -CloudId '' -Username 'you@example.com' -Token $token -Name 'sandbox' Get-ConfluenceSpace -Key 'DOCS' -Context 'sandbox' ### diff --git a/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 b/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 index ddf6482..981588d 100644 --- a/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 +++ b/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 @@ -2,7 +2,7 @@ function Get-ConfluenceBlogPost { <# .SYNOPSIS - Get blog posts (read:page). + Get blog posts (read:blogpost:confluence). .DESCRIPTION Returns a single blog post by id, the blog posts in a space, or all blog From e2824579715c0552a30b91accb53ee0e78cef015 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 12:13:06 +0200 Subject: [PATCH 15/21] =?UTF-8?q?=F0=9F=A9=B9=20[Patch]:=20Harden=20site?= =?UTF-8?q?=20URI=20parsing,=20surface=20site-info=20probe=20errors,=20avo?= =?UTF-8?q?id=20redundant=20config=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Config/Initialize-ConfluenceConfig.ps1 | 15 +++++++++++++-- .../public/Site/Get-ConfluenceCloudId.ps1 | 16 ++++++++++------ .../public/Site/Get-ConfluenceSiteInfo.ps1 | 5 +++++ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/functions/private/Config/Initialize-ConfluenceConfig.ps1 b/src/functions/private/Config/Initialize-ConfluenceConfig.ps1 index 7edd175..d9b993c 100644 --- a/src/functions/private/Config/Initialize-ConfluenceConfig.ps1 +++ b/src/functions/private/Config/Initialize-ConfluenceConfig.ps1 @@ -29,18 +29,29 @@ } } + $dirty = $false if ($Force -or -not $stored) { $config = ConvertTo-ConfluenceHashtable -InputObject $script:Confluence.DefaultConfig + $dirty = $true } else { $config = ConvertTo-ConfluenceHashtable -InputObject $stored foreach ($key in $script:Confluence.DefaultConfig.Keys) { if (-not $config.ContainsKey($key)) { $config[$key] = $script:Confluence.DefaultConfig[$key] + $dirty = $true } } } - $config['ID'] = $id - $null = Set-Context -ID $id -Context $config -Vault $vault + if ($config['ID'] -ne $id) { + $config['ID'] = $id + $dirty = $true + } + + # Only persist when we actually created or changed the stored config, to avoid + # unnecessary vault writes on every cmdlet call (this runs from most cmdlets). + if ($dirty) { + $null = Set-Context -ID $id -Context $config -Vault $vault + } $script:Confluence.Config = $config } diff --git a/src/functions/public/Site/Get-ConfluenceCloudId.ps1 b/src/functions/public/Site/Get-ConfluenceCloudId.ps1 index 2f1c177..dba457f 100644 --- a/src/functions/public/Site/Get-ConfluenceCloudId.ps1 +++ b/src/functions/public/Site/Get-ConfluenceCloudId.ps1 @@ -58,12 +58,16 @@ function Get-ConfluenceCloudId { } # Derive the host whether the caller passed a bare subdomain, a host, or a URL. - $siteHost = if ($trimmed -match '^[A-Za-z][A-Za-z0-9+.-]*://') { - ([uri]$trimmed).Host - } elseif ($trimmed -match '[/.]') { - ([uri]"https://$trimmed").Host - } else { - "$trimmed.atlassian.net" + try { + $siteHost = if ($trimmed -match '^[A-Za-z][A-Za-z0-9+.-]*://') { + ([uri]$trimmed).Host + } elseif ($trimmed -match '[/.]') { + ([uri]"https://$trimmed").Host + } else { + "$trimmed.atlassian.net" + } + } catch { + throw "Could not determine the site host from '$Site': $($_.Exception.Message)" } if ([string]::IsNullOrWhiteSpace($siteHost)) { diff --git a/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 b/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 index 38fa4f9..9fe3138 100644 --- a/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 +++ b/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 @@ -36,6 +36,7 @@ function Get-ConfluenceSiteInfo { # _links.base (the browsable site URL) appears on any v2 collection response; # try a couple so this works whether the token has read:space or only read:page. $siteUrl = $null + $lastError = $null foreach ($probe in '/wiki/api/v2/spaces', '/wiki/api/v2/pages') { try { $response = Invoke-ConfluenceRestMethod -ApiEndpoint $probe -Query @{ limit = 1 } -Context $Context @@ -44,9 +45,13 @@ function Get-ConfluenceSiteInfo { break } } catch { + $lastError = $_ continue } } + if (-not $siteUrl -and $lastError) { + Write-Verbose "Could not resolve the browsable site URL from the probe endpoints: $($lastError.Exception.Message)" + } [pscustomobject]@{ CloudId = $cloudId From 8b717c288b33562f2ac50edf27313456504a0d7d Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 12:26:17 +0200 Subject: [PATCH 16/21] =?UTF-8?q?=F0=9F=A9=B9=20[Patch]:=20Preserve=20page?= =?UTF-8?q?=20status=20on=20update;=20trash-then-purge=20for=20Remove-Conf?= =?UTF-8?q?luencePage=20-Purge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../public/Pages/Remove-ConfluencePage.ps1 | 28 +++++++++++++------ .../public/Pages/Set-ConfluencePage.ps1 | 7 +++-- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/functions/public/Pages/Remove-ConfluencePage.ps1 b/src/functions/public/Pages/Remove-ConfluencePage.ps1 index 85f4520..a57d02a 100644 --- a/src/functions/public/Pages/Remove-ConfluencePage.ps1 +++ b/src/functions/public/Pages/Remove-ConfluencePage.ps1 @@ -7,7 +7,9 @@ function Remove-ConfluencePage { .DESCRIPTION Moves a page to the trash. With -Recurse, direct and nested child pages are removed first so that a whole subtree can be deleted. With -Purge the - page is permanently deleted (only valid for already-trashed pages). + page is permanently deleted: because Confluence requires a page to be in + the trash before it can be purged, the page is trashed first and then + purged in a second call. .EXAMPLE ```powershell @@ -31,7 +33,8 @@ function Remove-ConfluencePage { # Remove child pages before removing the page itself. [switch]$Recurse, - # Permanently delete instead of moving to trash. + # Permanently delete the page. It is moved to the trash first (as the API + # requires) and then purged. [switch]$Purge, # The context to use: an object, a context name, or $null for the default. @@ -45,12 +48,21 @@ function Remove-ConfluencePage { } } - $endpoint = "/wiki/api/v2/pages/$PageId" - if ($Purge) { - $endpoint = '{0}?purge=true' -f $endpoint - } + $base = "/wiki/api/v2/pages/$PageId" + $action = if ($Purge) { 'Permanently delete Confluence page' } else { 'Delete Confluence page' } - if ($PSCmdlet.ShouldProcess($PageId, 'Delete Confluence page')) { - Invoke-ConfluenceRestMethod -ApiEndpoint $endpoint -Method 'DELETE' -Context $Context + if ($PSCmdlet.ShouldProcess($PageId, $action)) { + if ($Purge) { + # A page must be in the trash before it can be purged. Trash first + # (ignoring failures, e.g. when it is already trashed), then purge. + try { + Invoke-ConfluenceRestMethod -ApiEndpoint $base -Method 'DELETE' -Context $Context + } catch { + Write-Verbose "Trash step before purge failed (the page may already be trashed): $($_.Exception.Message)" + } + Invoke-ConfluenceRestMethod -ApiEndpoint ('{0}?purge=true' -f $base) -Method 'DELETE' -Context $Context + } else { + Invoke-ConfluenceRestMethod -ApiEndpoint $base -Method 'DELETE' -Context $Context + } } } diff --git a/src/functions/public/Pages/Set-ConfluencePage.ps1 b/src/functions/public/Pages/Set-ConfluencePage.ps1 index 96d7725..61d13dd 100644 --- a/src/functions/public/Pages/Set-ConfluencePage.ps1 +++ b/src/functions/public/Pages/Set-ConfluencePage.ps1 @@ -39,8 +39,8 @@ function Set-ConfluencePage { [ValidateSet('storage', 'atlas_doc_format', 'wiki')] [string]$Representation = 'storage', - # The page status. Defaults to 'current'. - [string]$Status = 'current', + # The page status. Defaults to the page's current status. + [string]$Status, # The context to use: an object, a context name, or $null for the default. [object]$Context @@ -50,10 +50,11 @@ function Set-ConfluencePage { $newTitle = if ($PSBoundParameters.ContainsKey('Title')) { $Title } else { $current.title } $newBody = if ($PSBoundParameters.ContainsKey('Body')) { $Body } else { $current.body.$Representation.value } + $newStatus = if ($PSBoundParameters.ContainsKey('Status')) { $Status } else { $current.status } $payload = @{ id = $PageId - status = $Status + status = $newStatus title = $newTitle version = @{ number = [int]$current.version.number + 1 From 6712085213a1603376732cc2c28cabdb8fb0ba03 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 12:32:30 +0200 Subject: [PATCH 17/21] =?UTF-8?q?=F0=9F=A9=B9=20[Patch]:=20Skip=20integrat?= =?UTF-8?q?ion=20tests=20unless=20all=20Confluence=20credentials=20are=20p?= =?UTF-8?q?resent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Confluence.Tests.ps1 | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/Confluence.Tests.ps1 b/tests/Confluence.Tests.ps1 index 75d240c..1b4c071 100644 --- a/tests/Confluence.Tests.ps1 +++ b/tests/Confluence.Tests.ps1 @@ -70,11 +70,17 @@ Describe 'Confluence' { } } - # Integration tests run only when live credentials are provided. The calling workflow supplies - # them through Process-PSModule's TestSecrets (CONFLUENCE_API_TOKEN, masked) and TestVariables - # (CONFLUENCE_SITE, CONFLUENCE_USERNAME, CONFLUENCE_SPACE_KEY), which are exposed as environment - # variables. They are skipped locally and wherever the API token is not available. - Context 'Integration' -Skip:([string]::IsNullOrEmpty($env:CONFLUENCE_API_TOKEN)) { + # Integration tests run only when ALL live credentials are provided. The calling workflow supplies + # them through Process-PSModule's TestData - CONFLUENCE_API_TOKEN under "secrets" (masked) and + # CONFLUENCE_SITE, CONFLUENCE_USERNAME and CONFLUENCE_SPACE_KEY under "variables" - exposed as + # environment variables. The context is skipped locally and whenever any of them is missing. + $missingIntegrationVars = @( + $env:CONFLUENCE_API_TOKEN + $env:CONFLUENCE_SITE + $env:CONFLUENCE_USERNAME + $env:CONFLUENCE_SPACE_KEY + ) | Where-Object { [string]::IsNullOrEmpty($_) } + Context 'Integration' -Skip:(@($missingIntegrationVars).Count -gt 0) { BeforeAll { $secureToken = ConvertTo-SecureString -String $env:CONFLUENCE_API_TOKEN -AsPlainText -Force $connectParams = @{ From a8dc0d74eb2670cbd718b7e767118d7efebb562d Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 12:39:21 +0200 Subject: [PATCH 18/21] =?UTF-8?q?=F0=9F=A9=B9=20[Patch]:=20Allow=20compati?= =?UTF-8?q?ble=20Context=208.x;=20document=20shortened=20scope=20form=20in?= =?UTF-8?q?=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SCOPES.md | 5 +++++ src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 | 2 +- src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/SCOPES.md b/SCOPES.md index 6ca6829..08be391 100644 --- a/SCOPES.md +++ b/SCOPES.md @@ -8,6 +8,11 @@ When you create the token for the service account, select **granular** Confluenc scopes (not the classic `*-confluence-*` scopes). Use this document when generating or rotating a token. +> Note: cmdlet help lists scopes in a shortened form (for example `read:page`), +> omitting the `:confluence` suffix for brevity. The canonical, fully-qualified +> scope strings you grant on the token are the ones in this document (for example +> `read:page:confluence`). + ## Configured scopes The token is currently configured with **48** granular Confluence scopes diff --git a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 index 3e736f3..37df0e9 100644 --- a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 +++ b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 @@ -1,5 +1,5 @@ #Requires -Version 7.0 -#Requires -Modules @{ ModuleName = 'Context'; RequiredVersion = '8.1.6' } +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6'; MaximumVersion = '8.*' } #SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. function Invoke-ConfluenceRestMethod { diff --git a/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 b/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 index 981588d..0f59aff 100644 --- a/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 +++ b/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 @@ -2,7 +2,7 @@ function Get-ConfluenceBlogPost { <# .SYNOPSIS - Get blog posts (read:blogpost:confluence). + Get blog posts (read:blogpost). .DESCRIPTION Returns a single blog post by id, the blog posts in a space, or all blog From 4a3ebf4100cae3ce7883d9306508e1918f2c7d53 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 8 Jul 2026 12:47:27 +0200 Subject: [PATCH 19/21] =?UTF-8?q?=F0=9F=A9=B9=20[Patch]:=20Use=20a=20concr?= =?UTF-8?q?ete=20Context=20MaximumVersion=20so=20the=20module=20manifest?= =?UTF-8?q?=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 index 37df0e9..5521733 100644 --- a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 +++ b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 @@ -1,5 +1,5 @@ #Requires -Version 7.0 -#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6'; MaximumVersion = '8.*' } +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6'; MaximumVersion = '8.999.999' } #SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. function Invoke-ConfluenceRestMethod { From 225a70b2912bf32fc12fe9340f81b6d74f0de627 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 9 Jul 2026 02:52:28 +0200 Subject: [PATCH 20/21] =?UTF-8?q?=F0=9F=A7=AA=20[Test]:=20Exercise=20every?= =?UTF-8?q?=20command=20with=20live=20CRUD=20and=20context=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite tests/Confluence.Tests.ps1 into a full integration suite that drives the module's own commands through create/read/update/delete for pages, folders, comments, labels, content properties and attachments, and reads spaces, permissions, properties, restrictions, users, blog posts and the raw REST entry point. Each command's processed object is logged with LogGroup so outputs are visible in the run log (matching the PSModule/GitHub test style). Add context-storage tests: profile fields, SecureString token, default and -ListAvailable contexts, multiple named contexts targeted with -Context, and config round-trip. Remove the #SkipTest:FunctionTest markers now that every public function is invoked by a test, so the source-code FunctionTest check passes without deferral warnings. Get-ConfluenceConfig now returns a defensive copy so callers cannot corrupt the in-memory configuration cache; changes still flow through Set-ConfluenceConfig. --- .../API/Invoke-ConfluenceRestMethod.ps1 | 1 - .../Attachments/Add-ConfluenceAttachment.ps1 | 3 +- .../Attachments/Get-ConfluenceAttachment.ps1 | 3 +- .../Remove-ConfluenceAttachment.ps1 | 3 +- .../public/Auth/Connect-Confluence.ps1 | 3 +- .../public/Auth/Disconnect-Confluence.ps1 | 3 +- .../Auth/Get-ConfluenceAccessibleResource.ps1 | 3 +- .../public/Auth/Get-ConfluenceContext.ps1 | 3 +- .../BlogPosts/Get-ConfluenceBlogPost.ps1 | 3 +- .../public/Comments/Add-ConfluenceComment.ps1 | 3 +- .../public/Comments/Get-ConfluenceComment.ps1 | 3 +- .../Comments/Remove-ConfluenceComment.ps1 | 3 +- .../public/Config/Get-ConfluenceConfig.ps1 | 8 +- .../public/Config/Set-ConfluenceConfig.ps1 | 3 +- .../Get-ConfluenceContentProperty.ps1 | 3 +- .../Remove-ConfluenceContentProperty.ps1 | 3 +- .../Set-ConfluenceContentProperty.ps1 | 3 +- .../public/Folders/Get-ConfluenceFolder.ps1 | 3 +- .../public/Folders/New-ConfluenceFolder.ps1 | 3 +- .../Folders/Remove-ConfluenceFolder.ps1 | 3 +- .../public/Labels/Add-ConfluenceLabel.ps1 | 3 +- .../public/Labels/Get-ConfluenceLabel.ps1 | 3 +- .../public/Labels/Remove-ConfluenceLabel.ps1 | 3 +- .../public/Pages/Get-ConfluenceDescendant.ps1 | 3 +- .../public/Pages/Get-ConfluencePage.ps1 | 3 +- .../public/Pages/Get-ConfluencePageChild.ps1 | 3 +- .../Pages/Get-ConfluencePageVersion.ps1 | 3 +- .../public/Pages/New-ConfluencePage.ps1 | 3 +- .../public/Pages/Remove-ConfluencePage.ps1 | 3 +- .../public/Pages/Set-ConfluencePage.ps1 | 3 +- .../Get-ConfluenceRestriction.ps1 | 3 +- .../public/Site/Get-ConfluenceCloudId.ps1 | 3 +- .../public/Site/Get-ConfluenceSiteInfo.ps1 | 3 +- .../public/Spaces/Get-ConfluenceSpace.ps1 | 3 +- .../Spaces/Get-ConfluenceSpacePermission.ps1 | 3 +- .../Spaces/Get-ConfluenceSpaceProperty.ps1 | 3 +- .../Users/Get-ConfluenceCurrentUser.ps1 | 3 +- tests/Confluence.Tests.ps1 | 618 +++++++++++++++++- 38 files changed, 642 insertions(+), 90 deletions(-) diff --git a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 index 5521733..add9d9b 100644 --- a/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 +++ b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 @@ -1,7 +1,6 @@ #Requires -Version 7.0 #Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6'; MaximumVersion = '8.999.999' } -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. function Invoke-ConfluenceRestMethod { <# .SYNOPSIS diff --git a/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 b/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 index ce3adf8..43e07ce 100644 --- a/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 +++ b/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Add-ConfluenceAttachment { +function Add-ConfluenceAttachment { <# .SYNOPSIS Upload an attachment to a page (read:content-details and write:attachment). diff --git a/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 b/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 index a4807b7..17a0388 100644 --- a/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 +++ b/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluenceAttachment { +function Get-ConfluenceAttachment { <# .SYNOPSIS Get page attachments (read:attachment). diff --git a/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 b/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 index d08ba7b..55bde99 100644 --- a/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 +++ b/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Remove-ConfluenceAttachment { +function Remove-ConfluenceAttachment { <# .SYNOPSIS Delete an attachment (delete:attachment). diff --git a/src/functions/public/Auth/Connect-Confluence.ps1 b/src/functions/public/Auth/Connect-Confluence.ps1 index db7cb70..c95bf43 100644 --- a/src/functions/public/Auth/Connect-Confluence.ps1 +++ b/src/functions/public/Auth/Connect-Confluence.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Connect-Confluence { +function Connect-Confluence { <# .SYNOPSIS Connect to Confluence and store a credential profile in the context vault. diff --git a/src/functions/public/Auth/Disconnect-Confluence.ps1 b/src/functions/public/Auth/Disconnect-Confluence.ps1 index 7009cb3..1ce16bf 100644 --- a/src/functions/public/Auth/Disconnect-Confluence.ps1 +++ b/src/functions/public/Auth/Disconnect-Confluence.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Disconnect-Confluence { +function Disconnect-Confluence { <# .SYNOPSIS Remove a stored Confluence credential profile. diff --git a/src/functions/public/Auth/Get-ConfluenceAccessibleResource.ps1 b/src/functions/public/Auth/Get-ConfluenceAccessibleResource.ps1 index 8f6e68e..78b8334 100644 --- a/src/functions/public/Auth/Get-ConfluenceAccessibleResource.ps1 +++ b/src/functions/public/Auth/Get-ConfluenceAccessibleResource.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Calls the public accessible-resources endpoint with a bearer token; covered by integration tests. -function Get-ConfluenceAccessibleResource { +function Get-ConfluenceAccessibleResource { <# .SYNOPSIS List the Atlassian sites (resources) a token can reach. diff --git a/src/functions/public/Auth/Get-ConfluenceContext.ps1 b/src/functions/public/Auth/Get-ConfluenceContext.ps1 index 9791c38..e6482c6 100644 --- a/src/functions/public/Auth/Get-ConfluenceContext.ps1 +++ b/src/functions/public/Auth/Get-ConfluenceContext.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluenceContext { +function Get-ConfluenceContext { <# .SYNOPSIS Get a stored Confluence credential profile. diff --git a/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 b/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 index 0f59aff..5711865 100644 --- a/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 +++ b/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluenceBlogPost { +function Get-ConfluenceBlogPost { <# .SYNOPSIS Get blog posts (read:blogpost). diff --git a/src/functions/public/Comments/Add-ConfluenceComment.ps1 b/src/functions/public/Comments/Add-ConfluenceComment.ps1 index 81c5544..8340025 100644 --- a/src/functions/public/Comments/Add-ConfluenceComment.ps1 +++ b/src/functions/public/Comments/Add-ConfluenceComment.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Add-ConfluenceComment { +function Add-ConfluenceComment { <# .SYNOPSIS Add a footer comment to a page (write:comment). diff --git a/src/functions/public/Comments/Get-ConfluenceComment.ps1 b/src/functions/public/Comments/Get-ConfluenceComment.ps1 index 78684f4..a1ddf80 100644 --- a/src/functions/public/Comments/Get-ConfluenceComment.ps1 +++ b/src/functions/public/Comments/Get-ConfluenceComment.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluenceComment { +function Get-ConfluenceComment { <# .SYNOPSIS List the footer comments on a page (read:comment). diff --git a/src/functions/public/Comments/Remove-ConfluenceComment.ps1 b/src/functions/public/Comments/Remove-ConfluenceComment.ps1 index 9a92a74..3fbf246 100644 --- a/src/functions/public/Comments/Remove-ConfluenceComment.ps1 +++ b/src/functions/public/Comments/Remove-ConfluenceComment.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Remove-ConfluenceComment { +function Remove-ConfluenceComment { <# .SYNOPSIS Delete a footer comment (delete:comment). diff --git a/src/functions/public/Config/Get-ConfluenceConfig.ps1 b/src/functions/public/Config/Get-ConfluenceConfig.ps1 index 8057009..00790b6 100644 --- a/src/functions/public/Config/Get-ConfluenceConfig.ps1 +++ b/src/functions/public/Config/Get-ConfluenceConfig.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluenceConfig { +function Get-ConfluenceConfig { <# .SYNOPSIS Get the Confluence module configuration. @@ -26,7 +25,10 @@ function Get-ConfluenceConfig { Initialize-ConfluenceConfig if ([string]::IsNullOrEmpty($Name)) { - return $script:Confluence.Config + # Return a shallow copy so callers cannot mutate the in-memory configuration cache by + # accident; changes must go through Set-ConfluenceConfig to be persisted. Cloning a small + # hashtable of scalars is negligible next to the vault I/O the module already performs. + return $script:Confluence.Config.Clone() } $script:Confluence.Config[$Name] } diff --git a/src/functions/public/Config/Set-ConfluenceConfig.ps1 b/src/functions/public/Config/Set-ConfluenceConfig.ps1 index 51d0343..9114da0 100644 --- a/src/functions/public/Config/Set-ConfluenceConfig.ps1 +++ b/src/functions/public/Config/Set-ConfluenceConfig.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Set-ConfluenceConfig { +function Set-ConfluenceConfig { <# .SYNOPSIS Set a Confluence module configuration value. diff --git a/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 b/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 index ee315eb..694456b 100644 --- a/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 +++ b/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluenceContentProperty { +function Get-ConfluenceContentProperty { <# .SYNOPSIS Get content properties of a page (read:page). diff --git a/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 b/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 index 6621e58..847605e 100644 --- a/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 +++ b/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Remove-ConfluenceContentProperty { +function Remove-ConfluenceContentProperty { <# .SYNOPSIS Delete a content property from a page (read:page and write:page). diff --git a/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 b/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 index 9191726..d77585a 100644 --- a/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 +++ b/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Set-ConfluenceContentProperty { +function Set-ConfluenceContentProperty { <# .SYNOPSIS Create or update a content property on a page (read:page and write:page). diff --git a/src/functions/public/Folders/Get-ConfluenceFolder.ps1 b/src/functions/public/Folders/Get-ConfluenceFolder.ps1 index f9a03ec..55ff2f3 100644 --- a/src/functions/public/Folders/Get-ConfluenceFolder.ps1 +++ b/src/functions/public/Folders/Get-ConfluenceFolder.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluenceFolder { +function Get-ConfluenceFolder { <# .SYNOPSIS Get a Confluence folder by id (read:folder). diff --git a/src/functions/public/Folders/New-ConfluenceFolder.ps1 b/src/functions/public/Folders/New-ConfluenceFolder.ps1 index 73ecedb..9b0270e 100644 --- a/src/functions/public/Folders/New-ConfluenceFolder.ps1 +++ b/src/functions/public/Folders/New-ConfluenceFolder.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function New-ConfluenceFolder { +function New-ConfluenceFolder { <# .SYNOPSIS Create a Confluence folder (write:folder). diff --git a/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 b/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 index 19dc6a8..83584eb 100644 --- a/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 +++ b/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Remove-ConfluenceFolder { +function Remove-ConfluenceFolder { <# .SYNOPSIS Delete a Confluence folder (delete:folder). diff --git a/src/functions/public/Labels/Add-ConfluenceLabel.ps1 b/src/functions/public/Labels/Add-ConfluenceLabel.ps1 index 1558f79..b3a7612 100644 --- a/src/functions/public/Labels/Add-ConfluenceLabel.ps1 +++ b/src/functions/public/Labels/Add-ConfluenceLabel.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Add-ConfluenceLabel { +function Add-ConfluenceLabel { <# .SYNOPSIS Add one or more labels to a page (read:label and write:label). diff --git a/src/functions/public/Labels/Get-ConfluenceLabel.ps1 b/src/functions/public/Labels/Get-ConfluenceLabel.ps1 index 325f779..fba6234 100644 --- a/src/functions/public/Labels/Get-ConfluenceLabel.ps1 +++ b/src/functions/public/Labels/Get-ConfluenceLabel.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluenceLabel { +function Get-ConfluenceLabel { <# .SYNOPSIS List the labels on a page (read:page). diff --git a/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 b/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 index d6dabf2..beecdb5 100644 --- a/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 +++ b/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Remove-ConfluenceLabel { +function Remove-ConfluenceLabel { <# .SYNOPSIS Remove a label from a page (write:label). diff --git a/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 b/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 index 94794ab..91aefdd 100644 --- a/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 +++ b/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluenceDescendant { +function Get-ConfluenceDescendant { <# .SYNOPSIS List all descendants of a page (read:hierarchical-content). diff --git a/src/functions/public/Pages/Get-ConfluencePage.ps1 b/src/functions/public/Pages/Get-ConfluencePage.ps1 index 5fd019a..70c0a9e 100644 --- a/src/functions/public/Pages/Get-ConfluencePage.ps1 +++ b/src/functions/public/Pages/Get-ConfluencePage.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluencePage { +function Get-ConfluencePage { <# .SYNOPSIS Get a Confluence page by id (read:page). diff --git a/src/functions/public/Pages/Get-ConfluencePageChild.ps1 b/src/functions/public/Pages/Get-ConfluencePageChild.ps1 index 859556e..33d7fb9 100644 --- a/src/functions/public/Pages/Get-ConfluencePageChild.ps1 +++ b/src/functions/public/Pages/Get-ConfluencePageChild.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluencePageChild { +function Get-ConfluencePageChild { <# .SYNOPSIS List the direct child pages of a page (read:page). diff --git a/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 b/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 index 62b40c3..af47530 100644 --- a/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 +++ b/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluencePageVersion { +function Get-ConfluencePageVersion { <# .SYNOPSIS List the version history of a page (read:page). diff --git a/src/functions/public/Pages/New-ConfluencePage.ps1 b/src/functions/public/Pages/New-ConfluencePage.ps1 index 421a7ec..4ba5cc7 100644 --- a/src/functions/public/Pages/New-ConfluencePage.ps1 +++ b/src/functions/public/Pages/New-ConfluencePage.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function New-ConfluencePage { +function New-ConfluencePage { <# .SYNOPSIS Create a Confluence page (write:page). diff --git a/src/functions/public/Pages/Remove-ConfluencePage.ps1 b/src/functions/public/Pages/Remove-ConfluencePage.ps1 index a57d02a..d2df8bb 100644 --- a/src/functions/public/Pages/Remove-ConfluencePage.ps1 +++ b/src/functions/public/Pages/Remove-ConfluencePage.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Remove-ConfluencePage { +function Remove-ConfluencePage { <# .SYNOPSIS Delete a Confluence page (delete:page). diff --git a/src/functions/public/Pages/Set-ConfluencePage.ps1 b/src/functions/public/Pages/Set-ConfluencePage.ps1 index 61d13dd..cb74bba 100644 --- a/src/functions/public/Pages/Set-ConfluencePage.ps1 +++ b/src/functions/public/Pages/Set-ConfluencePage.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Set-ConfluencePage { +function Set-ConfluencePage { <# .SYNOPSIS Update a Confluence page (read:page and write:page). diff --git a/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 b/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 index 268d903..b45a3a7 100644 --- a/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 +++ b/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluenceRestriction { +function Get-ConfluenceRestriction { <# .SYNOPSIS Get the content restrictions on a page (read:content-details). diff --git a/src/functions/public/Site/Get-ConfluenceCloudId.ps1 b/src/functions/public/Site/Get-ConfluenceCloudId.ps1 index dba457f..3a56291 100644 --- a/src/functions/public/Site/Get-ConfluenceCloudId.ps1 +++ b/src/functions/public/Site/Get-ConfluenceCloudId.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Resolves the cloud ID from the public tenant_info endpoint; covered by integration tests. -function Get-ConfluenceCloudId { +function Get-ConfluenceCloudId { <# .SYNOPSIS Get the cloud ID for a Confluence Cloud site. diff --git a/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 b/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 index 9fe3138..12f7ed3 100644 --- a/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 +++ b/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluenceSiteInfo { +function Get-ConfluenceSiteInfo { <# .SYNOPSIS Get basic information about the connected Confluence site (read:space or read:page). diff --git a/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 b/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 index b3f27ad..da91b8c 100644 --- a/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 +++ b/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluenceSpace { +function Get-ConfluenceSpace { <# .SYNOPSIS Get a Confluence space (read:space). diff --git a/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 b/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 index 0914c85..885ae91 100644 --- a/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 +++ b/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluenceSpacePermission { +function Get-ConfluenceSpacePermission { <# .SYNOPSIS List the permission assignments on a space (read:space). diff --git a/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 b/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 index 8b35dd1..38ae6d7 100644 --- a/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 +++ b/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluenceSpaceProperty { +function Get-ConfluenceSpaceProperty { <# .SYNOPSIS Get space properties (read:space). diff --git a/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 b/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 index d3c2544..3d66a5f 100644 --- a/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 +++ b/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 @@ -1,5 +1,4 @@ -#SkipTest:FunctionTest:Integration tests are added once the repository Confluence credentials are configured. -function Get-ConfluenceCurrentUser { +function Get-ConfluenceCurrentUser { <# .SYNOPSIS Get the current (authenticated) user — Confluence's "me" endpoint (read:content-details). diff --git a/tests/Confluence.Tests.ps1 b/tests/Confluence.Tests.ps1 index 1b4c071..ef356a7 100644 --- a/tests/Confluence.Tests.ps1 +++ b/tests/Confluence.Tests.ps1 @@ -4,9 +4,19 @@ Justification = 'Pester test cases assign variables that are used in other scopes.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'The API token is supplied as a CI environment secret and must be converted to a SecureString for Connect-Confluence.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', + Justification = 'Test output is written to the GitHub Actions log so the objects each command processes are visible.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', + Justification = 'Long test descriptions and pipeline output lines.')] [CmdletBinding()] param() +BeforeAll { + $testName = 'Confluence' + $os = $env:RUNNER_OS + $id = if ($env:GITHUB_RUN_ID) { $env:GITHUB_RUN_ID } else { 'local' } +} + Describe 'Confluence' { Context 'Module surface' { BeforeAll { @@ -37,21 +47,36 @@ Describe 'Confluence' { It 'exports the core REST and resource commands' { foreach ($name in @( 'Invoke-ConfluenceRestMethod' - 'Get-ConfluenceSpace' - 'Get-ConfluenceSiteInfo' - 'Get-ConfluenceCloudId' 'Get-ConfluenceAccessibleResource' + 'Get-ConfluenceCloudId' + 'Get-ConfluenceSiteInfo' + 'Get-ConfluenceSpace' + 'Get-ConfluenceSpacePermission' + 'Get-ConfluenceSpaceProperty' 'New-ConfluencePage' 'Get-ConfluencePage' 'Set-ConfluencePage' 'Remove-ConfluencePage' - 'Get-ConfluenceBlogPost' + 'Get-ConfluencePageChild' + 'Get-ConfluenceDescendant' + 'Get-ConfluencePageVersion' 'New-ConfluenceFolder' + 'Get-ConfluenceFolder' + 'Remove-ConfluenceFolder' 'Add-ConfluenceComment' + 'Get-ConfluenceComment' + 'Remove-ConfluenceComment' 'Add-ConfluenceLabel' + 'Get-ConfluenceLabel' + 'Remove-ConfluenceLabel' + 'Set-ConfluenceContentProperty' 'Get-ConfluenceContentProperty' + 'Remove-ConfluenceContentProperty' + 'Add-ConfluenceAttachment' 'Get-ConfluenceAttachment' + 'Remove-ConfluenceAttachment' 'Get-ConfluenceRestriction' + 'Get-ConfluenceBlogPost' 'Get-ConfluenceCurrentUser' )) { $commands | Should -Contain $name @@ -70,39 +95,600 @@ Describe 'Confluence' { } } - # Integration tests run only when ALL live credentials are provided. The calling workflow supplies - # them through Process-PSModule's TestData - CONFLUENCE_API_TOKEN under "secrets" (masked) and - # CONFLUENCE_SITE, CONFLUENCE_USERNAME and CONFLUENCE_SPACE_KEY under "variables" - exposed as - # environment variables. The context is skipped locally and whenever any of them is missing. + # Integration tests exercise every public command against a live Confluence Cloud site, + # logging the object each command processes so the output is visible in the run log. They run + # only when ALL live credentials are provided. The calling workflow supplies them through + # Process-PSModule's TestData - CONFLUENCE_API_TOKEN under "secrets" (masked) and CONFLUENCE_SITE, + # CONFLUENCE_USERNAME and CONFLUENCE_SPACE_KEY under "variables" - exposed as environment + # variables. The context is skipped locally and whenever any of them is missing. $missingIntegrationVars = @( $env:CONFLUENCE_API_TOKEN $env:CONFLUENCE_SITE $env:CONFLUENCE_USERNAME $env:CONFLUENCE_SPACE_KEY ) | Where-Object { [string]::IsNullOrEmpty($_) } + Context 'Integration' -Skip:(@($missingIntegrationVars).Count -gt 0) { BeforeAll { - $secureToken = ConvertTo-SecureString -String $env:CONFLUENCE_API_TOKEN -AsPlainText -Force + $script:secureToken = ConvertTo-SecureString -String $env:CONFLUENCE_API_TOKEN -AsPlainText -Force $connectParams = @{ Site = $env:CONFLUENCE_SITE Username = $env:CONFLUENCE_USERNAME - Token = $secureToken + Token = $script:secureToken SpaceKey = $env:CONFLUENCE_SPACE_KEY Name = 'ci' + PassThru = $true + } + $script:context = Connect-Confluence @connectParams + LogGroup 'Connected context' { + Write-Host ($script:context | Format-List | Out-String) + } + + # Resolve the target space once; its id is required to create pages and folders. + $script:space = Get-ConfluenceSpace -Key $env:CONFLUENCE_SPACE_KEY -Context 'ci' + $script:spaceId = $script:space.id + $script:homepageId = $script:space.homepageId + LogGroup "Space [$($env:CONFLUENCE_SPACE_KEY)]" { + Write-Host ($script:space | Format-List | Out-String) + } + + # A title prefix unique to this OS + workflow run so parallel OS jobs and reruns do not + # collide (Confluence page titles must be unique within a space). + $script:prefix = "$testName $os $id" + + # Best-effort: purge leftovers from a previous run of this same job (reruns reuse + # GITHUB_RUN_ID). Never fail the suite if cleanup is not possible. + if ($script:homepageId) { + try { + Get-ConfluencePageChild -PageId $script:homepageId -Context 'ci' | + Where-Object { $_.title -like "$($script:prefix)*" } | + ForEach-Object { Remove-ConfluencePage -PageId $_.id -Recurse -Purge -Context 'ci' -ErrorAction SilentlyContinue } + } catch { + Write-Warning "Stale-page cleanup skipped: $($_.Exception.Message)" + } } - Connect-Confluence @connectParams } AfterAll { - Disconnect-Confluence -Name 'ci' -ErrorAction SilentlyContinue + Get-ConfluenceContext -ListAvailable | ForEach-Object { Disconnect-Confluence -Name $_.Name -ErrorAction SilentlyContinue } + Write-Host ('-' * 60) + } + + Context 'Connection and context storage' { + It 'Connect-Confluence stored a reusable context profile' { + LogGroup 'Stored context' { + Write-Host ($script:context | Format-List | Out-String) + } + $script:context | Should -Not -BeNullOrEmpty + $script:context.Name | Should -Be 'ci' + $script:context.Type | Should -Be 'Confluence' + $script:context.ApiBaseUri | Should -Match '/ex/confluence/' + $script:context.Username | Should -Be $env:CONFLUENCE_USERNAME + $script:context.SpaceKey | Should -Be $env:CONFLUENCE_SPACE_KEY + } + + It 'stores the token as a SecureString, never as plain text' { + $stored = Get-ConfluenceContext -Name 'ci' + LogGroup 'Context token type' { + Write-Host "Token type: $($stored.Token.GetType().FullName)" + } + $stored.Token | Should -BeOfType [System.Security.SecureString] + } + + It 'Get-ConfluenceContext returns the default context' { + $default = Get-ConfluenceContext + LogGroup 'Default context' { + Write-Host ($default | Format-List | Out-String) + } + $default.Name | Should -Be 'ci' + } + + It 'Get-ConfluenceContext -ListAvailable excludes the module configuration' { + $available = Get-ConfluenceContext -ListAvailable + LogGroup 'Available contexts' { + Write-Host ($available | Format-Table -AutoSize | Out-String) + } + $available.Name | Should -Contain 'ci' + $available.ID | Should -Not -Contain 'Module' + } + + It 'supports multiple named contexts targeted with -Context' { + try { + $second = Connect-Confluence -Site $env:CONFLUENCE_SITE -Username $env:CONFLUENCE_USERNAME -Token $script:secureToken -SpaceKey $env:CONFLUENCE_SPACE_KEY -Name 'ci-secondary' -PassThru + LogGroup 'Second context' { + Write-Host ($second | Format-List | Out-String) + } + $second.Name | Should -Be 'ci-secondary' + (Get-ConfluenceContext -ListAvailable).Name | Should -Contain 'ci-secondary' + + # The named context can be used to target a call independently of the default. + Get-ConfluenceCurrentUser -Context 'ci-secondary' | Should -Not -BeNullOrEmpty + } finally { + Disconnect-Confluence -Name 'ci-secondary' + # Connect made the new profile the default; restore 'ci' for the remaining tests. + Set-ConfluenceConfig -Name 'DefaultContext' -Value 'ci' + } + (Get-ConfluenceContext -ListAvailable).Name | Should -Not -Contain 'ci-secondary' + } + } + + Context 'Configuration' { + It 'Get-ConfluenceConfig returns the module configuration' { + $config = Get-ConfluenceConfig + LogGroup 'Module configuration' { + Write-Host ($config | Format-Table -AutoSize | Out-String) + } + $config | Should -Not -BeNullOrEmpty + $config['DefaultContext'] | Should -Be 'ci' + $config['PerPage'] | Should -Not -BeNullOrEmpty + } + + It 'Set-ConfluenceConfig updates and persists a value' { + $original = Get-ConfluenceConfig -Name 'PerPage' + try { + Set-ConfluenceConfig -Name 'PerPage' -Value 42 + $updated = Get-ConfluenceConfig -Name 'PerPage' + LogGroup 'PerPage after update' { + Write-Host "PerPage: $updated" + } + $updated | Should -Be 42 + } finally { + Set-ConfluenceConfig -Name 'PerPage' -Value $original + } + Get-ConfluenceConfig -Name 'PerPage' | Should -Be $original + } + + It 'Get-ConfluenceConfig returns a copy that cannot corrupt the cache' { + $snapshot = Get-ConfluenceConfig + $snapshot['PerPage'] = 999999 + Get-ConfluenceConfig -Name 'PerPage' | Should -Not -Be 999999 + } + } + + Context 'Site and identity' { + It 'Get-ConfluenceCloudId resolves the site to its cloud ID' { + $cloudId = Get-ConfluenceCloudId -Site $env:CONFLUENCE_SITE + LogGroup 'Cloud ID' { + Write-Host "Site '$($env:CONFLUENCE_SITE)' -> $cloudId" + } + $cloudId | Should -Not -BeNullOrEmpty + $script:context.ApiBaseUri | Should -BeLike "*$cloudId*" + } + + It 'Get-ConfluenceSiteInfo reports the connected site' { + $info = Get-ConfluenceSiteInfo -Context 'ci' + LogGroup 'Site info' { + Write-Host ($info | Format-List | Out-String) + } + $info.CloudId | Should -Not -BeNullOrEmpty + $info.ApiBaseUri | Should -Be $script:context.ApiBaseUri + } + + It 'Get-ConfluenceCurrentUser returns the authenticated account' { + $user = Get-ConfluenceCurrentUser -Context 'ci' + LogGroup 'Current user' { + Write-Host ($user | Format-List | Out-String) + } + $user | Should -Not -BeNullOrEmpty + } + + It 'Get-ConfluenceAccessibleResource lists resources for the token' { + $resources = $null + $probeError = $null + try { + $resources = Get-ConfluenceAccessibleResource -Token $script:secureToken + } catch { + $probeError = $_ + } + LogGroup 'Accessible resources' { + if ($resources) { Write-Host ($resources | Format-List | Out-String) } + if ($probeError) { Write-Host "Endpoint did not return resources for this token type: $($probeError.Exception.Message)" } + } + # A scoped API token may not be accepted by the OAuth accessible-resources endpoint; + # the command must at least be callable and return data or surface a clear error. + ($null -ne $resources -or $null -ne $probeError) | Should -BeTrue + } + } + + Context 'Spaces' { + It 'Get-ConfluenceSpace resolves a space by key' { + $byKey = Get-ConfluenceSpace -Key $env:CONFLUENCE_SPACE_KEY -Context 'ci' + LogGroup 'Space by key' { + Write-Host ($byKey | Format-List | Out-String) + } + $byKey.key | Should -Be $env:CONFLUENCE_SPACE_KEY + $byKey.id | Should -Be $script:spaceId + } + + It 'Get-ConfluenceSpace resolves the same space by id' { + $byId = Get-ConfluenceSpace -Id $script:spaceId -Context 'ci' + LogGroup 'Space by id' { + Write-Host ($byId | Format-List | Out-String) + } + $byId.id | Should -Be $script:spaceId + } + + It 'Get-ConfluenceSpacePermission lists space permissions' { + $permissions = Get-ConfluenceSpacePermission -SpaceId $script:spaceId -Context 'ci' + LogGroup 'Space permissions' { + Write-Host ($permissions | Select-Object -First 5 | Format-List | Out-String) + Write-Host "Total permissions: $(@($permissions).Count)" + } + @($permissions).Count | Should -BeGreaterThan 0 + } + + It 'Get-ConfluenceSpaceProperty lists space properties' { + { $script:spaceProps = Get-ConfluenceSpaceProperty -SpaceId $script:spaceId -Context 'ci' } | Should -Not -Throw + LogGroup 'Space properties' { + Write-Host ($script:spaceProps | Format-List | Out-String) + Write-Host "Total properties: $(@($script:spaceProps).Count)" + } + } + } + + Context 'Pages - CRUD lifecycle' { + AfterAll { + if ($script:pagesRootId) { + Remove-ConfluencePage -PageId $script:pagesRootId -Recurse -Purge -Context 'ci' -ErrorAction SilentlyContinue + } + } + + It 'New-ConfluencePage creates a page' { + $title = "$($script:prefix) Pages Root" + $script:pagesRootId = $null + $page = New-ConfluencePage -SpaceId $script:spaceId -Title $title -Body '

Created by integration tests.

' -Context 'ci' + LogGroup "New page [$title]" { + Write-Host ($page | Format-List | Out-String) + } + $page | Should -Not -BeNullOrEmpty + $page.id | Should -Not -BeNullOrEmpty + $page.title | Should -Be $title + $page.spaceId | Should -Be $script:spaceId + $page.version.number | Should -Be 1 + $script:pagesRootId = $page.id + } + + It 'Get-ConfluencePage returns the created page with its body' { + $page = Get-ConfluencePage -PageId $script:pagesRootId -Context 'ci' + LogGroup 'Get page' { + Write-Host ($page | Format-List | Out-String) + Write-Host ($page.body | Format-List | Out-String) + } + $page.id | Should -Be $script:pagesRootId + $page.body.storage.value | Should -Match 'integration tests' + } + + It 'Set-ConfluencePage updates the body and increments the version' { + $newTitle = "$($script:prefix) Pages Root (updated)" + $updated = Set-ConfluencePage -PageId $script:pagesRootId -Title $newTitle -Body '

Updated body.

' -Context 'ci' + LogGroup 'Updated page' { + Write-Host ($updated | Format-List | Out-String) + } + $updated.version.number | Should -Be 2 + $updated.title | Should -Be $newTitle + (Get-ConfluencePage -PageId $script:pagesRootId -Context 'ci').body.storage.value | Should -Match 'Updated body' + } + + It 'Get-ConfluencePageVersion lists the version history' { + $versions = Get-ConfluencePageVersion -PageId $script:pagesRootId -Context 'ci' + LogGroup 'Page versions' { + Write-Host ($versions | Format-Table -AutoSize | Out-String) + } + @($versions).Count | Should -BeGreaterOrEqual 2 + } + + It 'New-ConfluencePage creates a child page under a parent' { + $title = "$($script:prefix) Pages Child" + $child = New-ConfluencePage -SpaceId $script:spaceId -Title $title -ParentId $script:pagesRootId -Body '

Child.

' -Context 'ci' + LogGroup "Child page [$title]" { + Write-Host ($child | Format-List | Out-String) + } + $child.id | Should -Not -BeNullOrEmpty + $script:pagesChildId = $child.id + } + + It 'Get-ConfluencePageChild lists the child pages' { + $children = Get-ConfluencePageChild -PageId $script:pagesRootId -Context 'ci' + LogGroup 'Child pages' { + Write-Host ($children | Format-Table -AutoSize | Out-String) + } + $children.id | Should -Contain $script:pagesChildId + } + + It 'Get-ConfluenceDescendant lists the descendants' { + $descendants = Get-ConfluenceDescendant -PageId $script:pagesRootId -Context 'ci' + LogGroup 'Descendants' { + Write-Host ($descendants | Format-Table -AutoSize | Out-String) + } + $descendants.id | Should -Contain $script:pagesChildId + } + + It 'Remove-ConfluencePage removes the page tree' { + { Remove-ConfluencePage -PageId $script:pagesRootId -Recurse -Purge -Context 'ci' } | Should -Not -Throw + LogGroup 'Removed page tree' { + Write-Host "Purged page $($script:pagesRootId) and its descendants." + } + { Get-ConfluencePage -PageId $script:pagesRootId -Context 'ci' } | Should -Throw + $script:pagesRootId = $null + } + } + + Context 'Folders - CRUD lifecycle' { + AfterAll { + if ($script:folderId) { + Remove-ConfluenceFolder -FolderId $script:folderId -Context 'ci' -ErrorAction SilentlyContinue + } + } + + It 'New-ConfluenceFolder creates a folder' { + $title = "$($script:prefix) Folder" + $folder = New-ConfluenceFolder -SpaceId $script:spaceId -Title $title -Context 'ci' + LogGroup "New folder [$title]" { + Write-Host ($folder | Format-List | Out-String) + } + $folder.id | Should -Not -BeNullOrEmpty + $folder.title | Should -Be $title + $script:folderId = $folder.id + } + + It 'Get-ConfluenceFolder returns the folder' { + $folder = Get-ConfluenceFolder -FolderId $script:folderId -Context 'ci' + LogGroup 'Get folder' { + Write-Host ($folder | Format-List | Out-String) + } + $folder.id | Should -Be $script:folderId + } + + It 'Remove-ConfluenceFolder deletes the folder' { + { Remove-ConfluenceFolder -FolderId $script:folderId -Context 'ci' } | Should -Not -Throw + LogGroup 'Removed folder' { + Write-Host "Deleted folder $($script:folderId)." + } + $script:folderId = $null + } + } + + Context 'Comments - CRUD lifecycle' { + BeforeAll { + $script:commentPage = New-ConfluencePage -SpaceId $script:spaceId -Title "$($script:prefix) Comments" -Body '

Comments fixture.

' -Context 'ci' + } + AfterAll { + if ($script:commentPage) { + Remove-ConfluencePage -PageId $script:commentPage.id -Recurse -Purge -Context 'ci' -ErrorAction SilentlyContinue + } + } + + It 'Add-ConfluenceComment adds a footer comment' { + $comment = Add-ConfluenceComment -PageId $script:commentPage.id -Body '

First comment.

' -Context 'ci' + LogGroup 'Added comment' { + Write-Host ($comment | Format-List | Out-String) + } + $comment.id | Should -Not -BeNullOrEmpty + $script:commentId = $comment.id + } + + It 'Get-ConfluenceComment lists the footer comments' { + $comments = Get-ConfluenceComment -PageId $script:commentPage.id -Context 'ci' + LogGroup 'Comments' { + Write-Host ($comments | Format-List | Out-String) + } + $comments.id | Should -Contain $script:commentId + } + + It 'Remove-ConfluenceComment deletes the comment' { + { Remove-ConfluenceComment -CommentId $script:commentId -Context 'ci' } | Should -Not -Throw + LogGroup 'Remaining comments' { + $remaining = Get-ConfluenceComment -PageId $script:commentPage.id -Context 'ci' + Write-Host ($remaining | Format-List | Out-String) + } + } + } + + Context 'Labels - CRUD lifecycle' { + BeforeAll { + $script:labelPage = New-ConfluencePage -SpaceId $script:spaceId -Title "$($script:prefix) Labels" -Body '

Labels fixture.

' -Context 'ci' + $digits = ($id -replace '\D', '') + $script:labelName = if ($digits) { "itest$digits" } else { 'itestlocal' } + } + AfterAll { + if ($script:labelPage) { + Remove-ConfluencePage -PageId $script:labelPage.id -Recurse -Purge -Context 'ci' -ErrorAction SilentlyContinue + } + } + + It 'Add-ConfluenceLabel adds labels to a page' { + $result = Add-ConfluenceLabel -PageId $script:labelPage.id -Label $script:labelName, "$($script:labelName)2" -Context 'ci' + LogGroup 'Added labels' { + Write-Host ($result | Format-List | Out-String) + } + $result | Should -Not -BeNullOrEmpty + } + + It 'Get-ConfluenceLabel lists the labels on the page' { + $labels = Get-ConfluenceLabel -PageId $script:labelPage.id -Context 'ci' + LogGroup 'Labels' { + Write-Host ($labels | Format-Table -AutoSize | Out-String) + } + $labels.name | Should -Contain $script:labelName + } + + It 'Remove-ConfluenceLabel removes a label' { + { Remove-ConfluenceLabel -PageId $script:labelPage.id -Label $script:labelName -Context 'ci' } | Should -Not -Throw + $labels = Get-ConfluenceLabel -PageId $script:labelPage.id -Context 'ci' + LogGroup 'Labels after removal' { + Write-Host ($labels | Format-Table -AutoSize | Out-String) + } + $labels.name | Should -Not -Contain $script:labelName + } + } + + Context 'Content properties - CRUD lifecycle' { + BeforeAll { + $script:propPage = New-ConfluencePage -SpaceId $script:spaceId -Title "$($script:prefix) Properties" -Body '

Properties fixture.

' -Context 'ci' + } + AfterAll { + if ($script:propPage) { + Remove-ConfluencePage -PageId $script:propPage.id -Recurse -Purge -Context 'ci' -ErrorAction SilentlyContinue + } + } + + It 'Set-ConfluenceContentProperty creates a property' { + $prop = Set-ConfluenceContentProperty -PageId $script:propPage.id -Key 'itest-owner' -Value @{ team = 'psmodule'; run = "$id" } -Context 'ci' + LogGroup 'Created property' { + Write-Host ($prop | Format-List | Out-String) + Write-Host ($prop.value | ConvertTo-Json -Depth 5) + } + $prop.key | Should -Be 'itest-owner' + $prop.version.number | Should -Be 1 + $script:propId = $prop.id + } + + It 'Get-ConfluenceContentProperty returns the property by key' { + $prop = Get-ConfluenceContentProperty -PageId $script:propPage.id -Key 'itest-owner' -Context 'ci' + LogGroup 'Fetched property' { + Write-Host ($prop | Format-List | Out-String) + } + $prop.value.team | Should -Be 'psmodule' + } + + It 'Set-ConfluenceContentProperty updates and versions the property' { + $prop = Set-ConfluenceContentProperty -PageId $script:propPage.id -Key 'itest-owner' -Value @{ team = 'ai-platform' } -Context 'ci' + LogGroup 'Updated property' { + Write-Host ($prop | Format-List | Out-String) + } + $prop.version.number | Should -Be 2 + (Get-ConfluenceContentProperty -PageId $script:propPage.id -Key 'itest-owner' -Context 'ci').value.team | Should -Be 'ai-platform' + } + + It 'Get-ConfluenceContentProperty lists all properties' { + $all = Get-ConfluenceContentProperty -PageId $script:propPage.id -Context 'ci' + LogGroup 'All properties' { + Write-Host ($all | Format-Table -AutoSize | Out-String) + } + $all.key | Should -Contain 'itest-owner' + } + + It 'Remove-ConfluenceContentProperty deletes the property' { + { Remove-ConfluenceContentProperty -PageId $script:propPage.id -PropertyId $script:propId -Context 'ci' } | Should -Not -Throw + Get-ConfluenceContentProperty -PageId $script:propPage.id -Key 'itest-owner' -Context 'ci' | Should -BeNullOrEmpty + } } - It 'resolves the current user' { - Get-ConfluenceCurrentUser -Context 'ci' | Should -Not -BeNullOrEmpty + Context 'Attachments - CRUD lifecycle' { + BeforeAll { + $script:attachPage = New-ConfluencePage -SpaceId $script:spaceId -Title "$($script:prefix) Attachments" -Body '

Attachments fixture.

' -Context 'ci' + $script:attachFile = Join-Path ([System.IO.Path]::GetTempPath()) "confluence-itest-$id-$os.txt" + Set-Content -Path $script:attachFile -Value "Integration test attachment $id" -Encoding utf8 + } + AfterAll { + if ($script:attachPage) { + Remove-ConfluencePage -PageId $script:attachPage.id -Recurse -Purge -Context 'ci' -ErrorAction SilentlyContinue + } + if ($script:attachFile -and (Test-Path $script:attachFile)) { + Remove-Item $script:attachFile -ErrorAction SilentlyContinue + } + } + + It 'Add-ConfluenceAttachment uploads a file' { + $upload = Add-ConfluenceAttachment -PageId $script:attachPage.id -Path $script:attachFile -Context 'ci' + LogGroup 'Uploaded attachment' { + Write-Host ($upload | ConvertTo-Json -Depth 6) + } + $upload | Should -Not -BeNullOrEmpty + } + + It 'Get-ConfluenceAttachment lists the attachments on the page' { + $attachments = Get-ConfluenceAttachment -PageId $script:attachPage.id -Context 'ci' + LogGroup 'Attachments on page' { + Write-Host ($attachments | Format-List | Out-String) + } + @($attachments).Count | Should -BeGreaterThan 0 + $script:attachmentId = @($attachments).id | Select-Object -First 1 + } + + It 'Get-ConfluenceAttachment returns a single attachment by id' { + $attachment = Get-ConfluenceAttachment -AttachmentId $script:attachmentId -Context 'ci' + LogGroup 'Single attachment' { + Write-Host ($attachment | Format-List | Out-String) + } + $attachment.id | Should -Be $script:attachmentId + } + + It 'Remove-ConfluenceAttachment deletes the attachment' { + { Remove-ConfluenceAttachment -AttachmentId $script:attachmentId -Context 'ci' } | Should -Not -Throw + LogGroup 'Attachments after removal' { + $remaining = Get-ConfluenceAttachment -PageId $script:attachPage.id -Context 'ci' + Write-Host ($remaining | Format-List | Out-String) + } + } + } + + Context 'Restrictions' { + BeforeAll { + $script:restrictPage = New-ConfluencePage -SpaceId $script:spaceId -Title "$($script:prefix) Restrictions" -Body '

Restrictions fixture.

' -Context 'ci' + } + AfterAll { + if ($script:restrictPage) { + Remove-ConfluencePage -PageId $script:restrictPage.id -Recurse -Purge -Context 'ci' -ErrorAction SilentlyContinue + } + } + + It 'Get-ConfluenceRestriction returns the read/update operations' { + $restrictions = Get-ConfluenceRestriction -PageId $script:restrictPage.id -Context 'ci' + LogGroup 'Restrictions' { + Write-Host ($restrictions | Format-List | Out-String) + } + $restrictions.operation | Should -Contain 'read' + } } - It 'resolves the configured space' { - (Get-ConfluenceSpace -Key $env:CONFLUENCE_SPACE_KEY -Context 'ci').key | Should -Be $env:CONFLUENCE_SPACE_KEY + Context 'Blog posts' { + It 'Get-ConfluenceBlogPost is callable (documents the read:blogpost scope gap)' { + # The test token intentionally does not carry read:blogpost:confluence (see SCOPES.md), + # so the /blogposts endpoint returns a scope-check failure. This confirms the error is + # surfaced clearly rather than swallowed; if the scope is later granted the call succeeds. + $posts = $null + $blogError = $null + try { + $posts = Get-ConfluenceBlogPost -SpaceId $script:spaceId -Context 'ci' + } catch { + $blogError = $_ + } + LogGroup 'Blog posts' { + if ($null -ne $posts) { Write-Host ($posts | Select-Object -First 5 | Format-List | Out-String) } + if ($blogError) { Write-Host "Scope-gated as expected: $($blogError.Exception.Message)" } + } + if ($blogError) { + $blogError.Exception.Message | Should -Match 'scope|403|blogpost' + } else { + $true | Should -BeTrue + } + } + } + + Context 'REST API' { + It 'Invoke-ConfluenceRestMethod calls the API directly' { + $response = Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/spaces' -Query @{ limit = 1 } -Context 'ci' + LogGroup 'Raw spaces response' { + Write-Host ($response | ConvertTo-Json -Depth 6) + } + $response.results | Should -Not -BeNullOrEmpty + } + + It 'Invoke-ConfluenceRestMethod -All aggregates paginated results' { + $spaces = Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/spaces' -All -Context 'ci' + LogGroup 'All spaces' { + Write-Host ($spaces | Select-Object id, key, name | Format-Table -AutoSize | Out-String) + Write-Host "Total spaces: $(@($spaces).Count)" + } + @($spaces).Count | Should -BeGreaterThan 0 + $spaces.key | Should -Contain $env:CONFLUENCE_SPACE_KEY + } + + It 'Invoke-ConfluenceRestMethod resolves a relative endpoint with -ApiVersion' { + $response = Invoke-ConfluenceRestMethod -ApiVersion v2 -ApiEndpoint 'spaces' -Query @{ limit = 1 } -Context 'ci' + $response.results | Should -Not -BeNullOrEmpty + } } } } From 85b625f5dbed68d0b5e1365bc2d53c3119bf07c5 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 9 Jul 2026 03:00:43 +0200 Subject: [PATCH 21/21] =?UTF-8?q?=F0=9F=A9=B9=20[Patch]:=20Retry=20attachm?= =?UTF-8?q?ent=20delete=20on=20transient=20409=20in=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confluence briefly returns HTTP 409 when an attachment is deleted immediately after upload (observed on the macOS runner while Linux/Windows passed). The Remove-ConfluenceAttachment test now retries the delete on a conflict with a short backoff so it reflects the eventual-consistency behaviour instead of flaking. --- tests/Confluence.Tests.ps1 | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/Confluence.Tests.ps1 b/tests/Confluence.Tests.ps1 index ef356a7..5c4dd49 100644 --- a/tests/Confluence.Tests.ps1 +++ b/tests/Confluence.Tests.ps1 @@ -615,11 +615,29 @@ Describe 'Confluence' { } It 'Remove-ConfluenceAttachment deletes the attachment' { - { Remove-ConfluenceAttachment -AttachmentId $script:attachmentId -Context 'ci' } | Should -Not -Throw + # Confluence can briefly return HTTP 409 when an attachment is deleted immediately + # after upload (it is still being processed server-side). Retry the delete on a + # conflict so the test reflects the eventual-consistency behaviour rather than flaking. + $deleted = $false + $lastError = $null + foreach ($attempt in 1..5) { + try { + Remove-ConfluenceAttachment -AttachmentId $script:attachmentId -Context 'ci' + $deleted = $true + break + } catch { + $lastError = $_ + if ($_.Exception.Message -notmatch '409|[Cc]onflict') { throw } + Start-Sleep -Seconds ($attempt * 2) + } + } LogGroup 'Attachments after removal' { + if ($deleted) { Write-Host "Deleted attachment $($script:attachmentId)." } + if ($lastError) { Write-Host "Transient conflict(s) handled: $($lastError.Exception.Message)" } $remaining = Get-ConfluenceAttachment -PageId $script:attachPage.id -Context 'ci' Write-Host ($remaining | Format-List | Out-String) } + $deleted | Should -BeTrue } }