diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index 93c1ddd..36aa723 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -27,5 +27,20 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@ce64918acc96dda73eb78f827036b794bfa6fa1a # v5.5.7 - secrets: inherit + # 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@ea019351821a74ae1528a4472198c84c91dd3059 # feat/52-test-secrets + secrets: + # PowerShell Gallery API key used to publish the module. + APIKey: ${{ secrets.APIKey }} + # 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) }} } } diff --git a/README.md b/README.md index df91bec..50b5b8e 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,51 @@ # Confluence -Confluence is intended to be a PowerShell module for interacting with Atlassian Confluence. +Confluence is a PowerShell module for interacting with the Atlassian Confluence Cloud REST API, both interactively and in automation. -## Status +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. -This repository is currently a placeholder. The module source still contains scaffold code and examples, so there are no supported Confluence-specific commands or usage examples to document yet. +## Installation + +Install the module from the PowerShell Gallery: + +```powershell +Install-PSResource -Name Confluence +Import-Module -Name Confluence +``` + +## Usage + +Connect with a scoped Atlassian API token, then call the resource commands. The token is kept as a `SecureString` in the Context vault. + +```powershell +# Connect and store a reusable, named credential profile +$token = Read-Host -AsSecureString # a scoped Atlassian API token +Connect-Confluence -Site 'yoursite' -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 +``` + +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 -When this module is implemented, command details should live in PowerShell help and generated documentation rather than being duplicated in this README. +Documentation is published at [psmodule.io/Confluence](https://psmodule.io/Confluence/). + +Use PowerShell help and command discovery for module details: + +```powershell +Get-Command -Module Confluence +Get-Help New-ConfluencePage -Examples +``` ## Contributing -Issues and pull requests are welcome when implementation work begins. +Issues and pull requests are welcome. Please use the repository issue tracker to report bugs, request features, or discuss improvements. diff --git a/SCOPES.md b/SCOPES.md new file mode 100644 index 0000000..08be391 --- /dev/null +++ b/SCOPES.md @@ -0,0 +1,158 @@ +# 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). 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 +(**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:analytics.content:confluence +read:attachment:confluence +read:comment: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: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:configuration:confluence +write:content:confluence +write:content.property: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 +``` + +## Scopes used by the current commands + +| 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: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` | + +> 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. 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; 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/). diff --git a/examples/Connecting.ps1 b/examples/Connecting.ps1 new file mode 100644 index 0000000..71d1e3c --- /dev/null +++ b/examples/Connecting.ps1 @@ -0,0 +1,50 @@ +<# + .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. +# -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 -Site 'yoursite' -Username 'you@example.com' -Token $token -SpaceKey 'DOCS' + +# Store several sites/accounts under explicit names and select per call. +# 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' + +### +### 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..e83ba77 --- /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 +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..5f65563 --- /dev/null +++ b/src/functions/private/Auth/Resolve-ConfluenceContext.ps1 @@ -0,0 +1,39 @@ +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..5736dca --- /dev/null +++ b/src/functions/private/Auth/Resolve-ConfluenceToken.ps1 @@ -0,0 +1,22 @@ +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..f90f38e --- /dev/null +++ b/src/functions/private/Config/ConvertTo-ConfluenceHashtable.ps1 @@ -0,0 +1,29 @@ +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..d9b993c --- /dev/null +++ b/src/functions/private/Config/Initialize-ConfluenceConfig.ps1 @@ -0,0 +1,57 @@ +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 + } + } + + $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 + } + } + } + + 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/API/Invoke-ConfluenceRestMethod.ps1 b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 new file mode 100644 index 0000000..add9d9b --- /dev/null +++ b/src/functions/public/API/Invoke-ConfluenceRestMethod.ps1 @@ -0,0 +1,194 @@ +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6'; MaximumVersion = '8.999.999' } + +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 + ```powershell + Invoke-ConfluenceRestMethod -ApiEndpoint '/wiki/api/v2/spaces' -Query @{ limit = 1 } + ``` + + 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 + 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). + # 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', + + # 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." + } + + # 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 + $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..43e07ce --- /dev/null +++ b/src/functions/public/Attachments/Add-ConfluenceAttachment.ps1 @@ -0,0 +1,49 @@ +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 + ```powershell + Add-ConfluenceAttachment -PageId '12345' -Path ./diagram.png + ``` + + 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)] + 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..17a0388 --- /dev/null +++ b/src/functions/public/Attachments/Get-ConfluenceAttachment.ps1 @@ -0,0 +1,41 @@ +function Get-ConfluenceAttachment { + <# + .SYNOPSIS + Get page attachments (read:attachment). + + .DESCRIPTION + Lists the attachments on a page, or returns a single attachment by id. + + .EXAMPLE + ```powershell + Get-ConfluenceAttachment -PageId '12345' + ``` + + 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')] + 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..55bde99 --- /dev/null +++ b/src/functions/public/Attachments/Remove-ConfluenceAttachment.ps1 @@ -0,0 +1,35 @@ +function Remove-ConfluenceAttachment { + <# + .SYNOPSIS + Delete an attachment (delete:attachment). + + .DESCRIPTION + Deletes an attachment by id. + + .EXAMPLE + ```powershell + Remove-ConfluenceAttachment -AttachmentId 'att12345' + ``` + + 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)] + 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..c95bf43 --- /dev/null +++ b/src/functions/public/Auth/Connect-Confluence.ps1 @@ -0,0 +1,140 @@ +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. 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 -Site 'msxorg' -Username $user -Token $token -SpaceKey 'DOCS' + ``` + + 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/ + + .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, DefaultParameterSetName = 'Site')] + [OutputType([pscustomobject])] + param( + # 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 `Get-ConfluenceCloudId` or `Get-ConfluenceAccessibleResource`. + [Parameter(Mandatory, ParameterSetName = 'CloudId')] + [string]$CloudId, + + # 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 ($PSCmdlet.ParameterSetName -eq 'Site') { + # Resolve a site name/host/URL to its cloud ID via the public tenant_info endpoint. + $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 + + 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 + Scopes = @() + } + + 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'." + } + + # 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 + + 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..1ce16bf --- /dev/null +++ b/src/functions/public/Auth/Disconnect-Confluence.ps1 @@ -0,0 +1,40 @@ +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 + ```powershell + Disconnect-Confluence -Name 'sandbox' + ``` + + Removes the stored 'sandbox' credential profile. + + .LINK + https://psmodule.io/Confluence/Functions/Auth/Disconnect-Confluence/ + #> + [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-ConfluenceAccessibleResource.ps1 b/src/functions/public/Auth/Get-ConfluenceAccessibleResource.ps1 new file mode 100644 index 0000000..78b8334 --- /dev/null +++ b/src/functions/public/Auth/Get-ConfluenceAccessibleResource.ps1 @@ -0,0 +1,61 @@ +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/functions/public/Auth/Get-ConfluenceContext.ps1 b/src/functions/public/Auth/Get-ConfluenceContext.ps1 new file mode 100644 index 0000000..e6482c6 --- /dev/null +++ b/src/functions/public/Auth/Get-ConfluenceContext.ps1 @@ -0,0 +1,44 @@ +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 + ```powershell + Get-ConfluenceContext + ``` + + Returns the current default context. + + .LINK + https://psmodule.io/Confluence/Functions/Auth/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..5711865 --- /dev/null +++ b/src/functions/public/BlogPosts/Get-ConfluenceBlogPost.ps1 @@ -0,0 +1,44 @@ +function Get-ConfluenceBlogPost { + <# + .SYNOPSIS + Get blog posts (read:blogpost). + + .DESCRIPTION + Returns a single blog post by id, the blog posts in a space, or all blog + posts across the site, following pagination. + + .EXAMPLE + ```powershell + Get-ConfluenceBlogPost -SpaceId '123456' + ``` + + 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')] + 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..8340025 --- /dev/null +++ b/src/functions/public/Comments/Add-ConfluenceComment.ps1 @@ -0,0 +1,51 @@ +function Add-ConfluenceComment { + <# + .SYNOPSIS + Add a footer comment to a page (write:comment). + + .DESCRIPTION + Creates a footer comment on the given page. + + .EXAMPLE + ```powershell + Add-ConfluenceComment -PageId '12345' -Body '

Nice page.

' + ``` + + 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)] + 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..a1ddf80 --- /dev/null +++ b/src/functions/public/Comments/Get-ConfluenceComment.ps1 @@ -0,0 +1,33 @@ +function Get-ConfluenceComment { + <# + .SYNOPSIS + List the footer comments on a page (read:comment). + + .DESCRIPTION + Returns every footer comment on the page, following pagination. + + .EXAMPLE + ```powershell + Get-ConfluenceComment -PageId '12345' + ``` + + 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()] + 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..3fbf246 --- /dev/null +++ b/src/functions/public/Comments/Remove-ConfluenceComment.ps1 @@ -0,0 +1,35 @@ +function Remove-ConfluenceComment { + <# + .SYNOPSIS + Delete a footer comment (delete:comment). + + .DESCRIPTION + Permanently deletes a footer comment by id. + + .EXAMPLE + ```powershell + Remove-ConfluenceComment -CommentId '55555' + ``` + + 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)] + 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..00790b6 --- /dev/null +++ b/src/functions/public/Config/Get-ConfluenceConfig.ps1 @@ -0,0 +1,34 @@ +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 + ```powershell + Get-ConfluenceConfig -Name 'DefaultContext' + ``` + + Gets the DefaultContext value from the module configuration. + + .LINK + https://psmodule.io/Confluence/Functions/Config/Get-ConfluenceConfig/ + #> + [CmdletBinding()] + param( + # The configuration item to return. If omitted, the whole config is returned. + [string]$Name + ) + + Initialize-ConfluenceConfig + if ([string]::IsNullOrEmpty($Name)) { + # 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 new file mode 100644 index 0000000..9114da0 --- /dev/null +++ b/src/functions/public/Config/Set-ConfluenceConfig.ps1 @@ -0,0 +1,37 @@ +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 + ```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( + # 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..694456b --- /dev/null +++ b/src/functions/public/ContentProperties/Get-ConfluenceContentProperty.ps1 @@ -0,0 +1,43 @@ +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 + ```powershell + Get-ConfluenceContentProperty -PageId '12345' -Key 'my-prop' + ``` + + 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()] + 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..847605e --- /dev/null +++ b/src/functions/public/ContentProperties/Remove-ConfluenceContentProperty.ps1 @@ -0,0 +1,40 @@ +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 + ```powershell + Remove-ConfluenceContentProperty -PageId '12345' -PropertyId '98765' + ``` + + 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)] + 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..d77585a --- /dev/null +++ b/src/functions/public/ContentProperties/Set-ConfluenceContentProperty.ps1 @@ -0,0 +1,66 @@ +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 + ```powershell + Set-ConfluenceContentProperty -PageId '12345' -Key 'owner' -Value @{ team = 'ai' } + ``` + + 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)] + 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..55ff2f3 --- /dev/null +++ b/src/functions/public/Folders/Get-ConfluenceFolder.ps1 @@ -0,0 +1,33 @@ +function Get-ConfluenceFolder { + <# + .SYNOPSIS + Get a Confluence folder by id (read:folder). + + .DESCRIPTION + Returns a single folder. + + .EXAMPLE + ```powershell + Get-ConfluenceFolder -FolderId '67890' + ``` + + 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()] + 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..9b0270e --- /dev/null +++ b/src/functions/public/Folders/New-ConfluenceFolder.ps1 @@ -0,0 +1,50 @@ +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 + ```powershell + New-ConfluenceFolder -SpaceId $spaceId -Title 'Archive' -ParentId $pageId + ``` + + 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)] + 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..83584eb --- /dev/null +++ b/src/functions/public/Folders/Remove-ConfluenceFolder.ps1 @@ -0,0 +1,35 @@ +function Remove-ConfluenceFolder { + <# + .SYNOPSIS + Delete a Confluence folder (delete:folder). + + .DESCRIPTION + Moves a folder to the trash. + + .EXAMPLE + ```powershell + Remove-ConfluenceFolder -FolderId '67890' + ``` + + 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)] + 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..b3a7612 --- /dev/null +++ b/src/functions/public/Labels/Add-ConfluenceLabel.ps1 @@ -0,0 +1,54 @@ +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 + ```powershell + Add-ConfluenceLabel -PageId '12345' -Label 'docs', 'published' + ``` + + 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)] + 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..fba6234 --- /dev/null +++ b/src/functions/public/Labels/Get-ConfluenceLabel.ps1 @@ -0,0 +1,35 @@ +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 + ```powershell + Get-ConfluenceLabel -PageId '12345' + ``` + + 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()] + 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..beecdb5 --- /dev/null +++ b/src/functions/public/Labels/Remove-ConfluenceLabel.ps1 @@ -0,0 +1,39 @@ +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 + ```powershell + Remove-ConfluenceLabel -PageId '12345' -Label 'docs' + ``` + + 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)] + 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..91aefdd --- /dev/null +++ b/src/functions/public/Pages/Get-ConfluenceDescendant.ps1 @@ -0,0 +1,34 @@ +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 + ```powershell + Get-ConfluenceDescendant -PageId '12345' + ``` + + 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()] + 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..70c0a9e --- /dev/null +++ b/src/functions/public/Pages/Get-ConfluencePage.ps1 @@ -0,0 +1,37 @@ +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 + ```powershell + Get-ConfluencePage -PageId '12345' + ``` + + 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()] + 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..33d7fb9 --- /dev/null +++ b/src/functions/public/Pages/Get-ConfluencePageChild.ps1 @@ -0,0 +1,33 @@ +function Get-ConfluencePageChild { + <# + .SYNOPSIS + List the direct child pages of a page (read:page). + + .DESCRIPTION + Returns every direct child page, following pagination automatically. + + .EXAMPLE + ```powershell + Get-ConfluencePageChild -PageId '12345' + ``` + + 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()] + 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..af47530 --- /dev/null +++ b/src/functions/public/Pages/Get-ConfluencePageVersion.ps1 @@ -0,0 +1,33 @@ +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 + ```powershell + Get-ConfluencePageVersion -PageId '12345' + ``` + + 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()] + 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..4ba5cc7 --- /dev/null +++ b/src/functions/public/Pages/New-ConfluencePage.ps1 @@ -0,0 +1,63 @@ +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 + ```powershell + New-ConfluencePage -SpaceId $spaceId -Title 'Docs' -Body '

Hello

' + ``` + + 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)] + 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..d2df8bb --- /dev/null +++ b/src/functions/public/Pages/Remove-ConfluencePage.ps1 @@ -0,0 +1,67 @@ +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: 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 + Remove-ConfluencePage -PageId '12345' -Recurse + ``` + + 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)] + param( + # The page id to delete. + [Parameter(Mandatory)] + [string]$PageId, + + # Remove child pages before removing the page itself. + [switch]$Recurse, + + # 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. + [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 + } + } + + $base = "/wiki/api/v2/pages/$PageId" + $action = if ($Purge) { 'Permanently delete Confluence page' } else { 'Delete Confluence page' } + + 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 new file mode 100644 index 0000000..cb74bba --- /dev/null +++ b/src/functions/public/Pages/Set-ConfluencePage.ps1 @@ -0,0 +1,70 @@ +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 + ```powershell + Set-ConfluencePage -PageId '12345' -Body '

Updated

' + ``` + + 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)] + 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 the page's current status. + [string]$Status, + + # 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 } + $newStatus = if ($PSBoundParameters.ContainsKey('Status')) { $Status } else { $current.status } + + $payload = @{ + id = $PageId + status = $newStatus + 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..b45a3a7 --- /dev/null +++ b/src/functions/public/Restrictions/Get-ConfluenceRestriction.ps1 @@ -0,0 +1,39 @@ +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 + ```powershell + Get-ConfluenceRestriction -PageId '12345' + ``` + + 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()] + 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-ConfluenceCloudId.ps1 b/src/functions/public/Site/Get-ConfluenceCloudId.ps1 new file mode 100644 index 0000000..3a56291 --- /dev/null +++ b/src/functions/public/Site/Get-ConfluenceCloudId.ps1 @@ -0,0 +1,91 @@ +function Get-ConfluenceCloudId { + <# + .SYNOPSIS + 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 (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 + Get-ConfluenceCloudId -Site 'msxorg' + ``` + + Returns the cloud ID for `https://msxorg.atlassian.net`. + + .EXAMPLE + ```powershell + 'https://msxorg.atlassian.net/wiki/spaces/DOCS' | Get-ConfluenceCloudId + ``` + + Resolves the cloud ID from a full site URL supplied through the pipeline. + + .EXAMPLE + ```powershell + Connect-Confluence -CloudId (Get-ConfluenceCloudId -Site 'msxorg') -Username $user -Token $token + ``` + + 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/Get-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. + 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)) { + 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/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 b/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 new file mode 100644 index 0000000..12f7ed3 --- /dev/null +++ b/src/functions/public/Site/Get-ConfluenceSiteInfo.ps1 @@ -0,0 +1,60 @@ +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 + ```powershell + Get-ConfluenceSiteInfo + ``` + + 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()] + [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 + $lastError = $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 { + $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 + 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..da91b8c --- /dev/null +++ b/src/functions/public/Spaces/Get-ConfluenceSpace.ps1 @@ -0,0 +1,51 @@ +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 + ```powershell + Get-ConfluenceSpace -Key 'DOCS' + ``` + + 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')] + 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..885ae91 --- /dev/null +++ b/src/functions/public/Spaces/Get-ConfluenceSpacePermission.ps1 @@ -0,0 +1,38 @@ +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 + ```powershell + Get-ConfluenceSpacePermission -SpaceId '123456' + ``` + + 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()] + 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..38ae6d7 --- /dev/null +++ b/src/functions/public/Spaces/Get-ConfluenceSpaceProperty.ps1 @@ -0,0 +1,40 @@ +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 + ```powershell + Get-ConfluenceSpaceProperty -SpaceId '123456' + ``` + + 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()] + 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..3d66a5f --- /dev/null +++ b/src/functions/public/Users/Get-ConfluenceCurrentUser.ps1 @@ -0,0 +1,33 @@ +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 + ```powershell + Get-ConfluenceCurrentUser + ``` + + 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()] + 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 deleted file mode 100644 index cc1fde9..0000000 --- a/src/header.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', Justification = 'Contains long links.')] -[CmdletBinding()] -param() 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/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..fc10b32 --- /dev/null +++ b/src/variables/private/Confluence.ps1 @@ -0,0 +1,17 @@ +$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 = '' + PerPage = 100 + } + Config = $null +} diff --git a/tests/Confluence.Tests.ps1 b/tests/Confluence.Tests.ps1 new file mode 100644 index 0000000..5c4dd49 --- /dev/null +++ b/tests/Confluence.Tests.ps1 @@ -0,0 +1,712 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[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.')] +[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 { + $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-ConfluenceAccessibleResource' + 'Get-ConfluenceCloudId' + 'Get-ConfluenceSiteInfo' + 'Get-ConfluenceSpace' + 'Get-ConfluenceSpacePermission' + 'Get-ConfluenceSpaceProperty' + 'New-ConfluencePage' + 'Get-ConfluencePage' + 'Set-ConfluencePage' + 'Remove-ConfluencePage' + '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 + } + } + + 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 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 { + $script:secureToken = ConvertTo-SecureString -String $env:CONFLUENCE_API_TOKEN -AsPlainText -Force + $connectParams = @{ + Site = $env:CONFLUENCE_SITE + Username = $env:CONFLUENCE_USERNAME + 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)" + } + } + } + + AfterAll { + 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 + } + } + + 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' { + # 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 + } + } + + 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' + } + } + + 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 + } + } + } +} diff --git a/tests/PSModuleTest.Tests.ps1 b/tests/PSModuleTest.Tests.ps1 deleted file mode 100644 index 85524cd..0000000 --- a/tests/PSModuleTest.Tests.ps1 +++ /dev/null @@ -1,7 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } - -Describe 'Module' { - It 'Function: Get-PSModuleTest' { - Get-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' - } -}