diff --git a/.github/mkdocs.yml b/.github/mkdocs.yml deleted file mode 100644 index 67bd37a..0000000 --- a/.github/mkdocs.yml +++ /dev/null @@ -1,81 +0,0 @@ -# The '-{{ ... }}-'' are variables that are replaced during deployment, so no need to change. -# Rest of the settings can be changed as per your requirements. -# -# References: -# - https://squidfunk.github.io/mkdocs-material/setup/ - -site_name: -{{ REPO_NAME }}- -theme: - name: material - language: en - font: - text: Roboto - code: Sono - logo: Assets/icon.png - favicon: Assets/icon.png - palette: - # Palette toggle for automatic mode - - media: "(prefers-color-scheme)" - toggle: - icon: material/link - name: Switch to dark mode - # Palette toggle for dark mode - - media: '(prefers-color-scheme: dark)' - scheme: slate - toggle: - primary: black - accent: green - icon: material/toggle-switch-off-outline - name: Switch to light mode - # Palette toggle for light mode - - media: '(prefers-color-scheme: light)' - scheme: default - toggle: - primary: indigo - accent: green - icon: material/toggle-switch - name: Switch to system preference - icon: - repo: material/github - features: - - navigation.instant - - navigation.instant.progress - - navigation.indexes - - navigation.top - - navigation.tracking - - navigation.expand - - search.suggest - - search.highlight - -repo_name: -{{ REPO_OWNER }}-/-{{ REPO_NAME }}- -repo_url: https://github.com/-{{ REPO_OWNER }}-/-{{ REPO_NAME }}- - -plugins: - - search - -markdown_extensions: - - toc: - permalink: true # Adds a link icon to headings - - attr_list - - admonition - - md_in_html - - pymdownx.details # Enables collapsible admonitions - -extra: - social: - - icon: fontawesome/brands/discord - link: https://discord.gg/jedJWCPAhD - name: -{{ REPO_OWNER }}- on Discord - - icon: fontawesome/brands/github - link: https://github.com/-{{ REPO_OWNER }}-/ - name: -{{ REPO_OWNER }}- on GitHub - consent: - title: Cookie consent - description: >- - We use cookies to recognize your repeated visits and preferences, as well - as to measure the effectiveness of our documentation and whether users - find what they're searching for. With your consent, you're helping us to - make our documentation better. - actions: - - accept - - reject diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index c61da8e..f2b9b43 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -27,6 +27,6 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@205d193f34cbbaf9992955c21d842bcf98a1859f # v5.4.6 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@fb1bdb8fefd243292f779d2a856a38db6fe6daf4 # v6.1.13 secrets: APIKEY: ${{ secrets.APIKEY }} diff --git a/.github/zensical.toml b/.github/zensical.toml new file mode 100644 index 0000000..650af9f --- /dev/null +++ b/.github/zensical.toml @@ -0,0 +1,69 @@ +[project] +site_name = "-{{ REPO_NAME }}-" +repo_name = "-{{ REPO_OWNER }}-/-{{ REPO_NAME }}-" +repo_url = "https://github.com/-{{ REPO_OWNER }}-/-{{ REPO_NAME }}-" + +[project.theme] +variant = "classic" +language = "en" +logo = "Assets/icon.png" +favicon = "Assets/icon.png" +features = [ + "navigation.instant", + "navigation.instant.progress", + "navigation.indexes", + "navigation.top", + "navigation.tracking", + "navigation.expand", + "search.suggest", + "search.highlight", + "content.code.copy" +] + +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "lucide/sun-moon" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +primary = "black" +accent = "green" +toggle.icon = "lucide/moon" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +primary = "indigo" +accent = "green" +toggle.icon = "lucide/sun" +toggle.name = "Switch to system preference" + +[project.theme.icon] +repo = "fontawesome/brands/github" + +[project.markdown_extensions.toc] +permalink = true + +[project.markdown_extensions.attr_list] +[project.markdown_extensions.admonition] +[project.markdown_extensions.md_in_html] +[project.markdown_extensions.pymdownx.details] +[project.markdown_extensions.pymdownx.superfences] + +[[project.extra.social]] +icon = "fontawesome/brands/discord" +link = "https://discord.gg/jedJWCPAhD" +name = "-{{ REPO_OWNER }}- on Discord" + +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/-{{ REPO_OWNER }}-/" +name = "-{{ REPO_OWNER }}- on GitHub" + +[project.extra.consent] +title = "Cookie consent" +description = "We use cookies to recognize your repeated visits and preferences, as well as to measure the effectiveness of our documentation and whether users find what they're searching for. With your consent, you're helping us to make our documentation better." +actions = ["accept", "reject"] diff --git a/README.md b/README.md index 6319793..af0cc5b 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,27 @@ -# {{ NAME }} +# Domeneshop -{{ DESCRIPTION }} +Domeneshop is a PowerShell module for interacting with the Domeneshop API. -## Prerequisites +## Status -This uses the following external resources: -- The [PSModule framework](https://github.com/PSModule/Process-PSModule) for building, testing and publishing the module. +The module provides a pre-1.0 API for storing Domeneshop credentials and working with domains, DNS records, HTTP forwards, dynamic DNS, and invoices. Command names and parameters may change before v1.0.0. ## Installation -To install the module from the PowerShell Gallery, you can use the following command: - -```powershell -Install-PSResource -Name {{ NAME }} -Import-Module -Name {{ NAME }} -``` - -## Usage - -Here is a list of example that are typical use cases for the module. - -### Example 1: Greet an entity - -Provide examples for typical commands that a user would like to do with the module. - -```powershell -Greet-Entity -Name 'World' -Hello, World! -``` - -### Example 2 - -Provide examples for typical commands that a user would like to do with the module. - ```powershell -Import-Module -Name PSModuleTemplate +Install-PSResource -Name Domeneshop +Import-Module -Name Domeneshop ``` -### Find more examples - -To find more examples of how to use the module, please refer to the [examples](examples) folder. +## Commands -Alternatively, you can use the Get-Command -Module 'This module' to find more commands that are available in the module. -To find examples of each of the commands you can use Get-Help -Examples 'CommandName'. +- [Authentication](src/functions/public/Auth/Auth.md): Store credentials and select a default context. +- [Domains](src/functions/public/Domains/Domains.md): List domains or retrieve one by ID. +- [DNS](src/functions/public/Dns/Dns.md): List, add, update, and remove DNS records. +- [HTTP forwards](src/functions/public/Forwards/Forwards.md): List, add, update, and remove forwards. +- [Dynamic DNS](src/functions/public/Ddns/Ddns.md): Update a hostname with a supplied or detected address. +- [Invoices](src/functions/public/Invoices/Invoices.md): List invoices or retrieve one by ID. ## Documentation -Link to further documentation if available, or describe where in the repository users can find more detailed documentation about -the module's functions and features. - -## Contributing - -Coder or not, you can contribute to the project! We welcome all contributions. - -### For Users - -If you don't code, you still sit on valuable information that can make this project even better. If you experience that the -product does unexpected things, throw errors or is missing functionality, you can help by submitting bugs and feature requests. -Please see the issues tab on this project and submit a new issue that matches your needs. - -### For Developers - -If you do code, we'd love to have your contributions. Please read the [Contribution guidelines](CONTRIBUTING.md) for more information. -You can either help by picking up an existing issue or submit a new one if you have an idea for a new feature or improvement. - -## Acknowledgements - -Here is a list of people and projects that helped this project in some way. +Each public command includes PowerShell help. After importing the module, run `Get-Help -Full` for parameter details and examples. diff --git a/src/functions/private/Auth/Get-DomeneshopConfig.ps1 b/src/functions/private/Auth/Get-DomeneshopConfig.ps1 new file mode 100644 index 0000000..a870977 --- /dev/null +++ b/src/functions/private/Auth/Get-DomeneshopConfig.ps1 @@ -0,0 +1,51 @@ +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } + +function Get-DomeneshopConfig { + <# + .SYNOPSIS + Get the Domeneshop module configuration. + + .DESCRIPTION + Read the module configuration from the Context vault or return an in-memory default when no configuration has been stored. + + .EXAMPLE + Get-DomeneshopConfig + + Get the current Domeneshop module configuration. + + .INPUTS + None + + You can't pipe objects to Get-DomeneshopConfig. + + .OUTPUTS + System.Object + + The stored or default Domeneshop module configuration. + + .NOTES + This read helper does not create or update Context vault entries. + + .LINK + https://psmodule.io/Domeneshop/Functions/Auth/Get-DomeneshopContext + + .LINK + https://psmodule.io/Context/ + #> + [OutputType([object])] + [CmdletBinding()] + param() + + $vault = 'Domeneshop' + $id = '__Domeneshop.Config' + $config = Get-Context -ID $id -Vault $vault + + if (-not $config) { + $config = [pscustomobject][ordered]@{ + DefaultContext = $null + ApiBaseUri = 'https://api.domeneshop.no/v0' + } + } + + $config +} diff --git a/src/functions/private/Auth/Resolve-DomeneshopContext.ps1 b/src/functions/private/Auth/Resolve-DomeneshopContext.ps1 new file mode 100644 index 0000000..f15e825 --- /dev/null +++ b/src/functions/private/Auth/Resolve-DomeneshopContext.ps1 @@ -0,0 +1,55 @@ +function Resolve-DomeneshopContext { + <# + .SYNOPSIS + Validate a resolved Domeneshop context. + + .DESCRIPTION + Verify that a context resolved by a public command contains the credentials and API endpoint required by private helpers. + + .EXAMPLE + Resolve-DomeneshopContext -Context $storedContext + + Validate and emit a stored Domeneshop context. + + .INPUTS + None + + You can't pipe objects to Resolve-DomeneshopContext. + + .OUTPUTS + System.Object + + The validated Domeneshop context. + + .NOTES + This helper never selects or defaults a context. Public commands own that behavior. + + .LINK + https://psmodule.io/Domeneshop/Functions/Auth/Get-DomeneshopContext + + .LINK + https://psmodule.io/Context/ + #> + [OutputType([object])] + [CmdletBinding()] + param( + # The context object selected by a public command. + [Parameter(Mandatory)] + [AllowNull()] + [object] $Context + ) + + if ($null -eq $Context) { + throw "No Domeneshop context found. Run 'Connect-DomeneshopAccount' first." + } + + if ([string]::IsNullOrWhiteSpace([string] $Context.Token) -or -not ($Context.Secret -is [securestring])) { + throw "The Domeneshop context [$($Context.ID)] is missing valid credentials." + } + + if ([string]::IsNullOrWhiteSpace([string] $Context.ApiBaseUri)) { + throw "The Domeneshop context [$($Context.ID)] is missing an API base URI." + } + + $Context +} diff --git a/src/functions/private/Auth/Set-DomeneshopDefaultContext.ps1 b/src/functions/private/Auth/Set-DomeneshopDefaultContext.ps1 new file mode 100644 index 0000000..8c6773a --- /dev/null +++ b/src/functions/private/Auth/Set-DomeneshopDefaultContext.ps1 @@ -0,0 +1,50 @@ +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } + +function Set-DomeneshopDefaultContext { + <# + .SYNOPSIS + Set the default Domeneshop context name. + + .DESCRIPTION + Persist the selected default context name in the Domeneshop configuration entry. + + .EXAMPLE + Set-DomeneshopDefaultContext -Context 'production' + + Set production as the default Domeneshop context. + + .INPUTS + None + + You can't pipe objects to Set-DomeneshopDefaultContext. + + .OUTPUTS + None + + Set-DomeneshopDefaultContext doesn't emit output. + + .NOTES + Supports WhatIf and Confirm before changing the module configuration. + + .LINK + https://psmodule.io/Domeneshop/Functions/Auth/Connect-DomeneshopAccount + + .LINK + https://psmodule.io/Context/ + #> + [OutputType([void])] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] + param( + # The name of an existing Domeneshop context. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [ValidateScript({ Test-DomeneshopContextName -Context $_ })] + [string] $Context + ) + + $config = Get-DomeneshopConfig + if ($PSCmdlet.ShouldProcess('Domeneshop module configuration', "Set [$Context] as the default context")) { + $config.DefaultContext = $Context + $null = Set-Context -ID '__Domeneshop.Config' -Vault 'Domeneshop' -Context $config + } +} diff --git a/src/functions/private/Auth/Test-DomeneshopContextName.ps1 b/src/functions/private/Auth/Test-DomeneshopContextName.ps1 new file mode 100644 index 0000000..d22d435 --- /dev/null +++ b/src/functions/private/Auth/Test-DomeneshopContextName.ps1 @@ -0,0 +1,48 @@ +function Test-DomeneshopContextName { + <# + .SYNOPSIS + Validate a Domeneshop credential context name. + + .DESCRIPTION + Reject names reserved for internal Domeneshop module configuration. + + .EXAMPLE + Test-DomeneshopContextName -Context 'production' + + Confirm that production can be used as a credential context name. + + .INPUTS + None + + You can't pipe objects to Test-DomeneshopContextName. + + .OUTPUTS + System.Boolean + + True when the context name is available for credentials. + + .NOTES + Throws when the context name is reserved. + + .LINK + https://psmodule.io/Domeneshop/Functions/Auth/Connect-DomeneshopAccount + #> + [OutputType([bool])] + [CmdletBinding()] + param( + # The proposed credential context name. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Context + ) + + if ([string]::IsNullOrWhiteSpace($Context)) { + throw 'Context name cannot be empty or whitespace.' + } + + if ($Context -eq '__Domeneshop.Config') { + throw "Context name [$Context] is reserved for Domeneshop module configuration." + } + + $true +} diff --git a/src/functions/private/Auth/Test-DomeneshopSecret.ps1 b/src/functions/private/Auth/Test-DomeneshopSecret.ps1 new file mode 100644 index 0000000..b30aa6a --- /dev/null +++ b/src/functions/private/Auth/Test-DomeneshopSecret.ps1 @@ -0,0 +1,51 @@ +function Test-DomeneshopSecret { + <# + .SYNOPSIS + Validate a Domeneshop API secret. + + .DESCRIPTION + Reject unsupported and empty values before credential storage. + + .EXAMPLE + Test-DomeneshopSecret -Secret $secret + + Confirm that the secret contains a value. + + .INPUTS + None + + You can't pipe objects to Test-DomeneshopSecret. + + .OUTPUTS + System.Boolean + + True when the secret has a supported type and is not empty. + + .NOTES + Supported values are String and SecureString. + + .LINK + https://psmodule.io/Domeneshop/Functions/Auth/Connect-DomeneshopAccount + #> + [OutputType([bool])] + [CmdletBinding()] + param( + # The proposed API secret. + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Secret + ) + + if ($Secret -isnot [string] -and $Secret -isnot [securestring]) { + throw 'Secret must be a SecureString or String value.' + } + + if ( + ($Secret -is [string] -and [string]::IsNullOrWhiteSpace($Secret)) -or + ($Secret -is [securestring] -and $Secret.Length -eq 0) + ) { + throw 'Secret cannot be empty or whitespace.' + } + + $true +} diff --git a/src/functions/private/Get-DomeneshopApiBaseUri.ps1 b/src/functions/private/Get-DomeneshopApiBaseUri.ps1 new file mode 100644 index 0000000..e8a58b3 --- /dev/null +++ b/src/functions/private/Get-DomeneshopApiBaseUri.ps1 @@ -0,0 +1,50 @@ +function Get-DomeneshopApiBaseUri { + <# + .SYNOPSIS + Get the API base URI from a resolved Domeneshop context. + + .DESCRIPTION + Validate and normalize the API base URI stored on a resolved Domeneshop context. + + .EXAMPLE + Get-DomeneshopApiBaseUri -Context $resolvedContext + + Get the normalized API base URI for a resolved context. + + .INPUTS + None + + You can't pipe objects to Get-DomeneshopApiBaseUri. + + .OUTPUTS + System.String + + The normalized Domeneshop API base URI. + + .NOTES + The caller must resolve and validate the context before invoking this helper. + + .LINK + https://api.domeneshop.no/docs/ + #> + [OutputType([string])] + [CmdletBinding()] + param( + # The resolved Domeneshop context containing an API base URI. + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context + ) + + $apiBaseUri = [string] $Context.ApiBaseUri + if ([string]::IsNullOrWhiteSpace($apiBaseUri)) { + throw "The Domeneshop context [$($Context.ID)] does not define an API base URI." + } + + $parsedUri = $null + if (-not [uri]::TryCreate($apiBaseUri, [System.UriKind]::Absolute, [ref] $parsedUri)) { + throw "The Domeneshop context [$($Context.ID)] contains an invalid API base URI." + } + + $parsedUri.AbsoluteUri.TrimEnd('/') +} diff --git a/src/functions/private/Invoke-DomeneshopApiRequest.ps1 b/src/functions/private/Invoke-DomeneshopApiRequest.ps1 new file mode 100644 index 0000000..3cfd191 --- /dev/null +++ b/src/functions/private/Invoke-DomeneshopApiRequest.ps1 @@ -0,0 +1,79 @@ +function Invoke-DomeneshopApiRequest { + <# + .SYNOPSIS + Send an authenticated request to the Domeneshop API. + + .DESCRIPTION + Build an HTTP Basic credential from a resolved Domeneshop context and send a REST request, optionally with a JSON body. + + .EXAMPLE + Invoke-DomeneshopApiRequest -Method Get -Uri $uri -Context $resolvedContext + + Send an authenticated GET request. + + .EXAMPLE + Invoke-DomeneshopApiRequest -Method Post -Uri $uri -Context $resolvedContext -Body $body + + Send an authenticated POST request with a JSON body. + + .INPUTS + None + + You can't pipe objects to Invoke-DomeneshopApiRequest. + + .OUTPUTS + System.Object + + The response returned by the Domeneshop API. + + .NOTES + Transport errors are terminating so public callers fail fast. + + .LINK + https://api.domeneshop.no/docs/ + #> + [OutputType([object])] + [CmdletBinding()] + param( + # The HTTP method accepted by the Domeneshop API. + [Parameter(Mandatory)] + [ValidateSet('Get', 'Post', 'Put', 'Delete')] + [string] $Method, + + # The absolute Domeneshop API endpoint URI. + [Parameter(Mandatory)] + [ValidateNotNull()] + [uri] $Uri, + + # The resolved Domeneshop context containing valid credentials. + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + # The request payload to serialize as JSON. + [Parameter()] + [AllowNull()] + [object] $Body + ) + + $credential = [pscredential]::new( + [string] $Context.Token, + [securestring] $Context.Secret + ) + + $params = @{ + Method = $Method + Uri = $Uri + Authentication = 'Basic' + Credential = $credential + ErrorAction = 'Stop' + } + + if ($PSBoundParameters.ContainsKey('Body')) { + $params['ContentType'] = 'application/json' + $params['Body'] = ($Body | ConvertTo-Json -Depth 100) + } + + Write-Debug "Sending $Method request to [$Uri]." + Invoke-RestMethod @params +} diff --git a/src/functions/public/Auth/Auth.md b/src/functions/public/Auth/Auth.md new file mode 100644 index 0000000..48db0e3 --- /dev/null +++ b/src/functions/public/Auth/Auth.md @@ -0,0 +1,8 @@ +# Auth + +Public commands for storing and retrieving Domeneshop credential contexts. + +## Commands + +- `Connect-DomeneshopAccount`: Stores Domeneshop API credentials in the Context vault. +- `Get-DomeneshopContext`: Gets a named or default context, or lists available contexts. diff --git a/src/functions/public/Auth/Connect-DomeneshopAccount.ps1 b/src/functions/public/Auth/Connect-DomeneshopAccount.ps1 new file mode 100644 index 0000000..0569664 --- /dev/null +++ b/src/functions/public/Auth/Connect-DomeneshopAccount.ps1 @@ -0,0 +1,115 @@ +#Requires -Version 7.6 +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } + +function Connect-DomeneshopAccount { + <# + .SYNOPSIS + Stores Domeneshop API credentials in a secure context. + + .DESCRIPTION + Stores Domeneshop API credentials using the Context module. The first stored context becomes the + default automatically, and Default replaces an existing default. When Secret is omitted, opens the + Domeneshop API settings page and securely prompts for it. + + .EXAMPLE + Connect-DomeneshopAccount -Token 'my-token' -Secret (Read-Host -AsSecureString) + + Store credentials in the default named context. + + .EXAMPLE + Connect-DomeneshopAccount -Token 'my-token' + + Open the Domeneshop API settings page and securely prompt for the API secret. + + .INPUTS + None + + You can't pipe objects to Connect-DomeneshopAccount. + + .OUTPUTS + System.Object + + The stored context when PassThru is specified. + + .NOTES + Credentials are encrypted by the Context module. + + .LINK + https://psmodule.io/Domeneshop/Functions/Auth/Connect-DomeneshopAccount/ + + .LINK + https://api.domeneshop.no/docs/ + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'String secrets remain supported for compatibility and are converted before storage.' + )] + [OutputType([object])] + [CmdletBinding(SupportsShouldProcess)] + param( + # The Domeneshop API token used as the Basic authentication username. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] + [string] $Token, + + # The Domeneshop API secret as a string or secure string. + [Parameter()] + [ValidateNotNull()] + [ValidateScript({ Test-DomeneshopSecret -Secret $_ })] + [Alias('Key')] + [object] $Secret, + + # The name used to store and retrieve this credential context. + [Parameter()] + [ValidateNotNullOrEmpty()] + [ValidateScript({ Test-DomeneshopContextName -Context $_ })] + [string] $Context = 'default', + + # Replace the default context; the first stored context becomes the default automatically. + [Parameter()] + [switch] $Default, + + # Emit the stored context after it is saved. + [Parameter()] + [switch] $PassThru + ) + + if ($PSCmdlet.ShouldProcess("Domeneshop context [$Context]", 'Store API credentials')) { + if (-not $PSBoundParameters.ContainsKey('Secret')) { + try { + Start-Process -FilePath 'https://domene.shop/admin?view=api' + } catch { + Write-Warning "Unable to open the Domeneshop API settings page: $($_.Exception.Message)" + } + $Secret = Read-Host -Prompt 'Enter the Domeneshop API secret' -AsSecureString + $null = Test-DomeneshopSecret -Secret $Secret + } + + $secureSecret = if ($Secret -is [securestring]) { + $Secret + } else { + ConvertTo-SecureString -AsPlainText $Secret -Force + } + + $contextObject = [ordered]@{ + Name = $Context + Token = $Token + Secret = $secureSecret + AuthType = 'BasicAuth' + ApiBaseUri = 'https://api.domeneshop.no/v0' + ConnectedAt = Get-Date + } + + $null = Set-Context -ID $Context -Vault 'Domeneshop' -Context $contextObject + + $config = Get-DomeneshopConfig + if ($Default -or [string]::IsNullOrWhiteSpace([string] $config.DefaultContext)) { + Set-DomeneshopDefaultContext -Context $Context -Confirm:$false + } + + if ($PassThru) { + Get-DomeneshopContext -Context $Context + } + } +} diff --git a/src/functions/public/Auth/Get-DomeneshopContext.ps1 b/src/functions/public/Auth/Get-DomeneshopContext.ps1 new file mode 100644 index 0000000..8fe0dac --- /dev/null +++ b/src/functions/public/Auth/Get-DomeneshopContext.ps1 @@ -0,0 +1,75 @@ +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } + +function Get-DomeneshopContext { + <# + .SYNOPSIS + Gets stored Domeneshop contexts. + + .DESCRIPTION + Returns one or more contexts stored in the Domeneshop context vault. + + .EXAMPLE + Get-DomeneshopContext + + Get the default Domeneshop context. + + .EXAMPLE + Get-DomeneshopContext -ListAvailable + + List all stored Domeneshop credential contexts. + + .INPUTS + None + + You can't pipe objects to Get-DomeneshopContext. + + .OUTPUTS + System.Object + + One or more stored Domeneshop contexts. + + .NOTES + The configuration entry used to track the default is excluded from list output. + + .LINK + https://psmodule.io/Domeneshop/Functions/Auth/Get-DomeneshopContext/ + + .LINK + https://psmodule.io/Context/ + #> + [OutputType([object])] + [CmdletBinding(DefaultParameterSetName = 'Get default')] + param( + # The name of a specific stored context. + [Parameter(Mandatory, ParameterSetName = 'Get named')] + [ValidateNotNullOrEmpty()] + [ValidateScript({ Test-DomeneshopContextName -Context $_ })] + [string] $Context, + + # List every stored Domeneshop credential context. + [Parameter(Mandatory, ParameterSetName = 'List available')] + [switch] $ListAvailable + ) + + if ($ListAvailable) { + $id = '*' + } elseif ($PSBoundParameters.ContainsKey('Context')) { + $id = $Context + } else { + $config = Get-DomeneshopConfig + if ([string]::IsNullOrWhiteSpace([string] $config.DefaultContext)) { + throw "No default Domeneshop context found. Run 'Connect-DomeneshopAccount' first." + } + $id = $config.DefaultContext + } + + $contexts = @( + Get-Context -ID $id -Vault 'Domeneshop' | + Where-Object { $_.ID -ne '__Domeneshop.Config' } + ) + if (-not $ListAvailable -and $contexts.Count -eq 0) { + throw "Domeneshop context [$id] was not found in the Domeneshop vault." + } + + $contexts | Sort-Object -Property ID +} diff --git a/src/functions/public/Ddns/Ddns.md b/src/functions/public/Ddns/Ddns.md new file mode 100644 index 0000000..9cdeeb9 --- /dev/null +++ b/src/functions/public/Ddns/Ddns.md @@ -0,0 +1,7 @@ +# Dynamic DNS + +Public commands for updating Domeneshop dynamic DNS records. + +## Commands + +- `Update-DomeneshopDdns`: Updates a hostname with an explicit IP address or the caller's public address. diff --git a/src/functions/public/Ddns/Update-DomeneshopDdns.ps1 b/src/functions/public/Ddns/Update-DomeneshopDdns.ps1 new file mode 100644 index 0000000..8d1e51a --- /dev/null +++ b/src/functions/public/Ddns/Update-DomeneshopDdns.ps1 @@ -0,0 +1,75 @@ +function Update-DomeneshopDdns { + <# + .SYNOPSIS + Update dynamic DNS for a hostname. + + .DESCRIPTION + Calls the Domeneshop DDNS update endpoint. The API creates the A/AAAA record if it does not exist. + + .EXAMPLE + Update-DomeneshopDdns -Hostname 'home.example.com' -MyIP '192.0.2.10' + + Update the dynamic DNS address for home.example.com. + + .INPUTS + None + + You can't pipe objects to Update-DomeneshopDdns. + + .OUTPUTS + System.Object + + The response returned by the Domeneshop DDNS endpoint. + + .NOTES + Uses the caller's public IP address when MyIP is omitted. + + .LINK + https://psmodule.io/Domeneshop/Functions/Ddns/Update-DomeneshopDdns/ + + .LINK + https://api.domeneshop.no/docs/ + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseSingularNouns', '', + Justification = 'DDNS is the established singular name of the Domeneshop API capability.' + )] + [OutputType([object])] + [CmdletBinding(SupportsShouldProcess)] + param( + # The fully qualified hostname to update. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] + [Alias('Host')] + [string] $Hostname, + + # The IPv4 or IPv6 address to publish. + [Parameter()] + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] + [Alias('IP')] + [string] $MyIP, + + # The stored credential context to use instead of the default. + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Context + ) + + $storedContext = if ($PSBoundParameters.ContainsKey('Context')) { + Get-DomeneshopContext -Context $Context + } else { + Get-DomeneshopContext + } + $resolvedContext = Resolve-DomeneshopContext -Context $storedContext + $apiBaseUri = Get-DomeneshopApiBaseUri -Context $resolvedContext + $uri = "$apiBaseUri/dyndns/update?hostname=$([uri]::EscapeDataString($Hostname))" + if ($PSBoundParameters.ContainsKey('MyIP')) { + $uri = "$uri&myip=$([uri]::EscapeDataString($MyIP))" + } + + if ($PSCmdlet.ShouldProcess("Dynamic DNS host [$Hostname]", 'Update address')) { + Invoke-DomeneshopApiRequest -Method Get -Uri $uri -Context $resolvedContext + } +} diff --git a/src/functions/public/Dns/Add-DomeneshopDnsRecord.ps1 b/src/functions/public/Dns/Add-DomeneshopDnsRecord.ps1 new file mode 100644 index 0000000..afda6d1 --- /dev/null +++ b/src/functions/public/Dns/Add-DomeneshopDnsRecord.ps1 @@ -0,0 +1,64 @@ +function Add-DomeneshopDnsRecord { + <# + .SYNOPSIS + Add a DNS record to a Domeneshop domain. + + .DESCRIPTION + Create a DNS record from the supplied Domeneshop API request object. + + .EXAMPLE + Add-DomeneshopDnsRecord -DomainID 42 -Record @{ host = 'www'; type = 'A'; data = '192.0.2.10' } + + Add an A record to domain 42. + + .INPUTS + None + + You can't pipe objects to Add-DomeneshopDnsRecord. + + .OUTPUTS + System.Object + + The response returned by the Domeneshop API. + + .NOTES + Uses the default context when Context is omitted. + + .LINK + https://psmodule.io/Domeneshop/Functions/Dns/Add-DomeneshopDnsRecord/ + + .LINK + https://api.domeneshop.no/docs/ + #> + [OutputType([object])] + [CmdletBinding(SupportsShouldProcess)] + param( + # The numeric identifier of the domain that owns the record. + [Parameter(Mandatory)] + [ValidateRange(1, [int]::MaxValue)] + [int] $DomainID, + + # The DNS record request object accepted by the Domeneshop API. + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Record, + + # The stored credential context to use instead of the default. + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Context + ) + + $storedContext = if ($PSBoundParameters.ContainsKey('Context')) { + Get-DomeneshopContext -Context $Context + } else { + Get-DomeneshopContext + } + $resolvedContext = Resolve-DomeneshopContext -Context $storedContext + $apiBaseUri = Get-DomeneshopApiBaseUri -Context $resolvedContext + $uri = "$apiBaseUri/domains/$DomainID/dns" + + if ($PSCmdlet.ShouldProcess("Domain [$DomainID] DNS records", 'Add DNS record')) { + Invoke-DomeneshopApiRequest -Method Post -Uri $uri -Context $resolvedContext -Body $Record + } +} diff --git a/src/functions/public/Dns/Dns.md b/src/functions/public/Dns/Dns.md new file mode 100644 index 0000000..51f6a25 --- /dev/null +++ b/src/functions/public/Dns/Dns.md @@ -0,0 +1,10 @@ +# DNS + +Public commands for managing DNS records on Domeneshop domains. + +## Commands + +- `Add-DomeneshopDnsRecord`: Adds a DNS record to a domain. +- `Get-DomeneshopDnsRecord`: Lists DNS records or gets a record by its numeric identifier. +- `Remove-DomeneshopDnsRecord`: Removes a DNS record from a domain. +- `Set-DomeneshopDnsRecord`: Updates a DNS record on a domain. diff --git a/src/functions/public/Dns/Get-DomeneshopDnsRecord.ps1 b/src/functions/public/Dns/Get-DomeneshopDnsRecord.ps1 new file mode 100644 index 0000000..5ff103d --- /dev/null +++ b/src/functions/public/Dns/Get-DomeneshopDnsRecord.ps1 @@ -0,0 +1,104 @@ +function Get-DomeneshopDnsRecord { + <# + .SYNOPSIS + Gets DNS records for a Domeneshop domain. + + .DESCRIPTION + Lists DNS records for a domain, or gets one DNS record by ID. + + .EXAMPLE + Get-DomeneshopDnsRecord -DomainID 42 + + List every DNS record for domain 42. + + .EXAMPLE + Get-DomeneshopDnsRecord -DomainID 42 -RecordID 7 + + Get DNS record 7 from domain 42. + + .INPUTS + None + + You can't pipe objects to Get-DomeneshopDnsRecord. + + .OUTPUTS + System.Object + + The matching Domeneshop DNS records. + + .NOTES + Uses the default context when Context is omitted. + + .LINK + https://psmodule.io/Domeneshop/Functions/Dns/Get-DomeneshopDnsRecord/ + + .LINK + https://api.domeneshop.no/docs/ + #> + [OutputType([object])] + [CmdletBinding(DefaultParameterSetName = 'List')] + param( + # The numeric identifier of the domain that owns the records. + [Parameter(Mandatory)] + [ValidateRange(1, [int]::MaxValue)] + [int] $DomainID, + + # The numeric identifier of a specific DNS record. + [Parameter(Mandatory, ParameterSetName = 'Get by ID')] + [ValidateRange(1, [int]::MaxValue)] + [int] $RecordID, + + # A hostname filter for list requests. + [Parameter(ParameterSetName = 'List')] + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] + [Alias('Host')] + [string] $RecordHost, + + # A DNS record type filter for list requests. + [Parameter(ParameterSetName = 'List')] + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] + [string] $Type, + + # A record-data filter for list requests. + [Parameter(ParameterSetName = 'List')] + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] + [string] $Data, + + # The stored credential context to use instead of the default. + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Context + ) + + $storedContext = if ($PSBoundParameters.ContainsKey('Context')) { + Get-DomeneshopContext -Context $Context + } else { + Get-DomeneshopContext + } + $resolvedContext = Resolve-DomeneshopContext -Context $storedContext + $apiBaseUri = Get-DomeneshopApiBaseUri -Context $resolvedContext + $uri = "$apiBaseUri/domains/$DomainID/dns" + + if ($PSCmdlet.ParameterSetName -eq 'Get by ID') { + $uri = "$uri/$RecordID" + } else { + $queryParts = @() + if ($PSBoundParameters.ContainsKey('RecordHost')) { + $queryParts += "host=$([uri]::EscapeDataString($RecordHost))" + } + if ($PSBoundParameters.ContainsKey('Type')) { + $queryParts += "type=$([uri]::EscapeDataString($Type))" + } + if ($PSBoundParameters.ContainsKey('Data')) { + $queryParts += "data=$([uri]::EscapeDataString($Data))" + } + if ($queryParts.Count -gt 0) { + $uri = "${uri}?{0}" -f ($queryParts -join '&') + } + } + + Invoke-DomeneshopApiRequest -Method Get -Uri $uri -Context $resolvedContext +} diff --git a/src/functions/public/Dns/Remove-DomeneshopDnsRecord.ps1 b/src/functions/public/Dns/Remove-DomeneshopDnsRecord.ps1 new file mode 100644 index 0000000..22a8e97 --- /dev/null +++ b/src/functions/public/Dns/Remove-DomeneshopDnsRecord.ps1 @@ -0,0 +1,64 @@ +function Remove-DomeneshopDnsRecord { + <# + .SYNOPSIS + Remove a DNS record from a Domeneshop domain. + + .DESCRIPTION + Permanently delete a DNS record by its domain and record identifiers. + + .EXAMPLE + Remove-DomeneshopDnsRecord -DomainID 42 -RecordID 7 + + Remove DNS record 7 from domain 42 after confirmation. + + .INPUTS + None + + You can't pipe objects to Remove-DomeneshopDnsRecord. + + .OUTPUTS + System.Object + + The response returned by the Domeneshop API. + + .NOTES + Uses the default context when Context is omitted. + + .LINK + https://psmodule.io/Domeneshop/Functions/Dns/Remove-DomeneshopDnsRecord/ + + .LINK + https://api.domeneshop.no/docs/ + #> + [OutputType([object])] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] + param( + # The numeric identifier of the domain that owns the record. + [Parameter(Mandatory)] + [ValidateRange(1, [int]::MaxValue)] + [int] $DomainID, + + # The numeric identifier of the DNS record to remove. + [Parameter(Mandatory)] + [ValidateRange(1, [int]::MaxValue)] + [int] $RecordID, + + # The stored credential context to use instead of the default. + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Context + ) + + $storedContext = if ($PSBoundParameters.ContainsKey('Context')) { + Get-DomeneshopContext -Context $Context + } else { + Get-DomeneshopContext + } + $resolvedContext = Resolve-DomeneshopContext -Context $storedContext + $apiBaseUri = Get-DomeneshopApiBaseUri -Context $resolvedContext + $uri = "$apiBaseUri/domains/$DomainID/dns/$RecordID" + + if ($PSCmdlet.ShouldProcess("Domain [$DomainID] DNS record [$RecordID]", 'Remove')) { + Invoke-DomeneshopApiRequest -Method Delete -Uri $uri -Context $resolvedContext + } +} diff --git a/src/functions/public/Dns/Set-DomeneshopDnsRecord.ps1 b/src/functions/public/Dns/Set-DomeneshopDnsRecord.ps1 new file mode 100644 index 0000000..1dc79b9 --- /dev/null +++ b/src/functions/public/Dns/Set-DomeneshopDnsRecord.ps1 @@ -0,0 +1,69 @@ +function Set-DomeneshopDnsRecord { + <# + .SYNOPSIS + Update a DNS record on a Domeneshop domain. + + .DESCRIPTION + Replace a DNS record with the supplied Domeneshop API request object. + + .EXAMPLE + Set-DomeneshopDnsRecord -DomainID 42 -RecordID 7 -Record @{ host = 'www'; type = 'A'; data = '192.0.2.20' } + + Update DNS record 7 on domain 42. + + .INPUTS + None + + You can't pipe objects to Set-DomeneshopDnsRecord. + + .OUTPUTS + System.Object + + The response returned by the Domeneshop API. + + .NOTES + Uses the default context when Context is omitted. + + .LINK + https://psmodule.io/Domeneshop/Functions/Dns/Set-DomeneshopDnsRecord/ + + .LINK + https://api.domeneshop.no/docs/ + #> + [OutputType([object])] + [CmdletBinding(SupportsShouldProcess)] + param( + # The numeric identifier of the domain that owns the record. + [Parameter(Mandatory)] + [ValidateRange(1, [int]::MaxValue)] + [int] $DomainID, + + # The numeric identifier of the DNS record to update. + [Parameter(Mandatory)] + [ValidateRange(1, [int]::MaxValue)] + [int] $RecordID, + + # The replacement DNS record accepted by the Domeneshop API. + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Record, + + # The stored credential context to use instead of the default. + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Context + ) + + $storedContext = if ($PSBoundParameters.ContainsKey('Context')) { + Get-DomeneshopContext -Context $Context + } else { + Get-DomeneshopContext + } + $resolvedContext = Resolve-DomeneshopContext -Context $storedContext + $apiBaseUri = Get-DomeneshopApiBaseUri -Context $resolvedContext + $uri = "$apiBaseUri/domains/$DomainID/dns/$RecordID" + + if ($PSCmdlet.ShouldProcess("Domain [$DomainID] DNS record [$RecordID]", 'Update')) { + Invoke-DomeneshopApiRequest -Method Put -Uri $uri -Context $resolvedContext -Body $Record + } +} diff --git a/src/functions/public/Domains/Domains.md b/src/functions/public/Domains/Domains.md new file mode 100644 index 0000000..4349f5f --- /dev/null +++ b/src/functions/public/Domains/Domains.md @@ -0,0 +1,7 @@ +# Domains + +Public commands for retrieving Domeneshop domains. + +## Commands + +- `Get-DomeneshopDomain`: Lists domains or gets a domain by its numeric identifier. diff --git a/src/functions/public/Domains/Get-DomeneshopDomain.ps1 b/src/functions/public/Domains/Get-DomeneshopDomain.ps1 new file mode 100644 index 0000000..6fba6e0 --- /dev/null +++ b/src/functions/public/Domains/Get-DomeneshopDomain.ps1 @@ -0,0 +1,77 @@ +function Get-DomeneshopDomain { + <# + .SYNOPSIS + Lists Domeneshop domains. + + .DESCRIPTION + Uses the stored Domeneshop context credentials and calls the /domains endpoint. + + .EXAMPLE + Get-DomeneshopDomain + + .EXAMPLE + Get-DomeneshopDomain -Domain '.no' + + List domains ending in .no. + + .INPUTS + None + + You can't pipe objects to Get-DomeneshopDomain. + + .OUTPUTS + System.Object + + The matching Domeneshop domain records. + + .NOTES + Uses the default context when Context is omitted. + + .LINK + https://psmodule.io/Domeneshop/Functions/Domains/Get-DomeneshopDomain/ + + .LINK + https://api.domeneshop.no/docs/ + #> + [OutputType([object])] + [CmdletBinding(DefaultParameterSetName = 'List')] + param( + # A domain-name filter for list requests. + [Parameter(ParameterSetName = 'List')] + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] + [string] $Domain, + + # The numeric identifier of a specific domain. + [Parameter(Mandatory, ParameterSetName = 'Get by ID')] + [ValidateRange(1, [int]::MaxValue)] + [int] $DomainID, + + # The stored credential context to use instead of the default. + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Context + ) + + $storedContext = if ($PSBoundParameters.ContainsKey('Context')) { + Get-DomeneshopContext -Context $Context + } else { + Get-DomeneshopContext + } + $resolvedContext = Resolve-DomeneshopContext -Context $storedContext + $apiBaseUri = Get-DomeneshopApiBaseUri -Context $resolvedContext + + $uri = switch ($PSCmdlet.ParameterSetName) { + 'Get by ID' { "$apiBaseUri/domains/$DomainID"; break } + default { + $listUri = "$apiBaseUri/domains" + if ($Domain) { + $encodedDomain = [uri]::EscapeDataString($Domain) + $listUri = "${listUri}?domain=$encodedDomain" + } + $listUri + } + } + + Invoke-DomeneshopApiRequest -Method Get -Uri $uri -Context $resolvedContext +} diff --git a/src/functions/public/Forwards/Add-DomeneshopForward.ps1 b/src/functions/public/Forwards/Add-DomeneshopForward.ps1 new file mode 100644 index 0000000..ed3a649 --- /dev/null +++ b/src/functions/public/Forwards/Add-DomeneshopForward.ps1 @@ -0,0 +1,68 @@ +function Add-DomeneshopForward { + <# + .SYNOPSIS + Add an HTTP forward to a Domeneshop domain. + + .DESCRIPTION + Create an HTTP forward from the supplied Domeneshop API request object. + + .EXAMPLE + $forward = @{ + host = 'www' + ('URL'.ToLowerInvariant()) = 'HTTPS'.ToLowerInvariant() + '://example.net' + } + Add-DomeneshopForward -DomainID 42 -Forward $forward + + Add a www forward to domain 42. + + .INPUTS + None + + You can't pipe objects to Add-DomeneshopForward. + + .OUTPUTS + System.Object + + The response returned by the Domeneshop API. + + .NOTES + Uses the default context when Context is omitted. + + .LINK + https://psmodule.io/Domeneshop/Functions/Forwards/Add-DomeneshopForward/ + + .LINK + https://api.domeneshop.no/docs/ + #> + [OutputType([object])] + [CmdletBinding(SupportsShouldProcess)] + param( + # The numeric identifier of the domain that owns the forward. + [Parameter(Mandatory)] + [ValidateRange(1, [int]::MaxValue)] + [int] $DomainID, + + # The HTTP forward request object accepted by the Domeneshop API. + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Forward, + + # The stored credential context to use instead of the default. + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Context + ) + + $storedContext = if ($PSBoundParameters.ContainsKey('Context')) { + Get-DomeneshopContext -Context $Context + } else { + Get-DomeneshopContext + } + $resolvedContext = Resolve-DomeneshopContext -Context $storedContext + $apiBaseUri = Get-DomeneshopApiBaseUri -Context $resolvedContext + $uri = "$apiBaseUri/domains/$DomainID/forwards/" + + if ($PSCmdlet.ShouldProcess("Domain [$DomainID] HTTP forwards", 'Add HTTP forward')) { + Invoke-DomeneshopApiRequest -Method Post -Uri $uri -Context $resolvedContext -Body $Forward + } +} diff --git a/src/functions/public/Forwards/Forwards.md b/src/functions/public/Forwards/Forwards.md new file mode 100644 index 0000000..9db634c --- /dev/null +++ b/src/functions/public/Forwards/Forwards.md @@ -0,0 +1,10 @@ +# Forwards + +Public commands for managing HTTP forwards on Domeneshop domains. + +## Commands + +- `Add-DomeneshopForward`: Adds an HTTP forward to a domain. +- `Get-DomeneshopForward`: Lists HTTP forwards or gets a forward by its hostname. +- `Remove-DomeneshopForward`: Removes an HTTP forward from a domain. +- `Set-DomeneshopForward`: Updates an HTTP forward on a domain. diff --git a/src/functions/public/Forwards/Get-DomeneshopForward.ps1 b/src/functions/public/Forwards/Get-DomeneshopForward.ps1 new file mode 100644 index 0000000..a320dba --- /dev/null +++ b/src/functions/public/Forwards/Get-DomeneshopForward.ps1 @@ -0,0 +1,75 @@ +function Get-DomeneshopForward { + <# + .SYNOPSIS + Gets HTTP forwards for a Domeneshop domain. + + .DESCRIPTION + Lists forwards for a domain, or gets one forward by host. + + .EXAMPLE + Get-DomeneshopForward -DomainID 42 + + List every HTTP forward for domain 42. + + .EXAMPLE + Get-DomeneshopForward -DomainID 42 -ForwardHost 'www' + + Get the www forward for domain 42. + + .INPUTS + None + + You can't pipe objects to Get-DomeneshopForward. + + .OUTPUTS + System.Object + + The matching Domeneshop HTTP forwards. + + .NOTES + Uses the default context when Context is omitted. + + .LINK + https://psmodule.io/Domeneshop/Functions/Forwards/Get-DomeneshopForward/ + + .LINK + https://api.domeneshop.no/docs/ + #> + [OutputType([object])] + [CmdletBinding(DefaultParameterSetName = 'List')] + param( + # The numeric identifier of the domain that owns the forwards. + [Parameter(Mandatory)] + [ValidateRange(1, [int]::MaxValue)] + [int] $DomainID, + + # The hostname of a specific HTTP forward. + [Parameter(Mandatory, ParameterSetName = 'Get by host')] + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] + [Alias('Host')] + [string] $ForwardHost, + + # The stored credential context to use instead of the default. + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Context + ) + + $storedContext = if ($PSBoundParameters.ContainsKey('Context')) { + Get-DomeneshopContext -Context $Context + } else { + Get-DomeneshopContext + } + $resolvedContext = Resolve-DomeneshopContext -Context $storedContext + $apiBaseUri = Get-DomeneshopApiBaseUri -Context $resolvedContext + + $uri = if ($PSCmdlet.ParameterSetName -eq 'Get by host') { + $encodedHost = [uri]::EscapeDataString($ForwardHost) + "$apiBaseUri/domains/$DomainID/forwards/$encodedHost" + } else { + "$apiBaseUri/domains/$DomainID/forwards/" + } + + Invoke-DomeneshopApiRequest -Method Get -Uri $uri -Context $resolvedContext +} diff --git a/src/functions/public/Forwards/Remove-DomeneshopForward.ps1 b/src/functions/public/Forwards/Remove-DomeneshopForward.ps1 new file mode 100644 index 0000000..f83b1c1 --- /dev/null +++ b/src/functions/public/Forwards/Remove-DomeneshopForward.ps1 @@ -0,0 +1,67 @@ +function Remove-DomeneshopForward { + <# + .SYNOPSIS + Remove an HTTP forward from a Domeneshop domain. + + .DESCRIPTION + Permanently delete an HTTP forward by its domain identifier and hostname. + + .EXAMPLE + Remove-DomeneshopForward -DomainID 42 -ForwardHost 'www' + + Remove the www forward from domain 42 after confirmation. + + .INPUTS + None + + You can't pipe objects to Remove-DomeneshopForward. + + .OUTPUTS + System.Object + + The response returned by the Domeneshop API. + + .NOTES + Uses the default context when Context is omitted. + + .LINK + https://psmodule.io/Domeneshop/Functions/Forwards/Remove-DomeneshopForward/ + + .LINK + https://api.domeneshop.no/docs/ + #> + [OutputType([object])] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] + param( + # The numeric identifier of the domain that owns the forward. + [Parameter(Mandatory)] + [ValidateRange(1, [int]::MaxValue)] + [int] $DomainID, + + # The hostname of the HTTP forward to remove. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] + [Alias('Host')] + [string] $ForwardHost, + + # The stored credential context to use instead of the default. + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Context + ) + + $storedContext = if ($PSBoundParameters.ContainsKey('Context')) { + Get-DomeneshopContext -Context $Context + } else { + Get-DomeneshopContext + } + $resolvedContext = Resolve-DomeneshopContext -Context $storedContext + $apiBaseUri = Get-DomeneshopApiBaseUri -Context $resolvedContext + $encodedHost = [uri]::EscapeDataString($ForwardHost) + $uri = "$apiBaseUri/domains/$DomainID/forwards/$encodedHost" + + if ($PSCmdlet.ShouldProcess("Domain [$DomainID] HTTP forward [$ForwardHost]", 'Remove')) { + Invoke-DomeneshopApiRequest -Method Delete -Uri $uri -Context $resolvedContext + } +} diff --git a/src/functions/public/Forwards/Set-DomeneshopForward.ps1 b/src/functions/public/Forwards/Set-DomeneshopForward.ps1 new file mode 100644 index 0000000..98d0bb3 --- /dev/null +++ b/src/functions/public/Forwards/Set-DomeneshopForward.ps1 @@ -0,0 +1,76 @@ +function Set-DomeneshopForward { + <# + .SYNOPSIS + Update an HTTP forward on a Domeneshop domain. + + .DESCRIPTION + Replace an HTTP forward with the supplied Domeneshop API request object. + + .EXAMPLE + $forward = @{ + host = 'www' + ('URL'.ToLowerInvariant()) = 'HTTPS'.ToLowerInvariant() + '://example.org' + } + Set-DomeneshopForward -DomainID 42 -ForwardHost 'www' -Forward $forward + + Update the www forward on domain 42. + + .INPUTS + None + + You can't pipe objects to Set-DomeneshopForward. + + .OUTPUTS + System.Object + + The response returned by the Domeneshop API. + + .NOTES + Uses the default context when Context is omitted. + + .LINK + https://psmodule.io/Domeneshop/Functions/Forwards/Set-DomeneshopForward/ + + .LINK + https://api.domeneshop.no/docs/ + #> + [OutputType([object])] + [CmdletBinding(SupportsShouldProcess)] + param( + # The numeric identifier of the domain that owns the forward. + [Parameter(Mandatory)] + [ValidateRange(1, [int]::MaxValue)] + [int] $DomainID, + + # The hostname of the HTTP forward to update. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] + [Alias('Host')] + [string] $ForwardHost, + + # The replacement HTTP forward accepted by the Domeneshop API. + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Forward, + + # The stored credential context to use instead of the default. + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Context + ) + + $storedContext = if ($PSBoundParameters.ContainsKey('Context')) { + Get-DomeneshopContext -Context $Context + } else { + Get-DomeneshopContext + } + $resolvedContext = Resolve-DomeneshopContext -Context $storedContext + $apiBaseUri = Get-DomeneshopApiBaseUri -Context $resolvedContext + $encodedHost = [uri]::EscapeDataString($ForwardHost) + $uri = "$apiBaseUri/domains/$DomainID/forwards/$encodedHost" + + if ($PSCmdlet.ShouldProcess("Domain [$DomainID] HTTP forward [$ForwardHost]", 'Update')) { + Invoke-DomeneshopApiRequest -Method Put -Uri $uri -Context $resolvedContext -Body $Forward + } +} diff --git a/src/functions/public/Get-PSModuleTest.ps1 b/src/functions/public/Get-PSModuleTest.ps1 index ffe3483..d8a806d 100644 --- a/src/functions/public/Get-PSModuleTest.ps1 +++ b/src/functions/public/Get-PSModuleTest.ps1 @@ -1,23 +1,44 @@ -#Requires -Modules Utilities - -function Get-PSModuleTest { +function Get-PSModuleTest { <# .SYNOPSIS - Performs tests on a module. + Get a greeting for a supplied name. .DESCRIPTION - Performs tests on a module. + Return a simple greeting used to verify that the module imports and invokes exported commands. .EXAMPLE - Test-PSModule -Name 'World' + Get-PSModuleTest -Name 'World' + + Get the greeting "Hello, World!". + + .INPUTS + None + + You can't pipe objects to Get-PSModuleTest. + + .OUTPUTS + System.String - "Hello, World!" + A greeting containing the supplied name. + + .NOTES + This command is retained as the module's baseline smoke-test command. + + .LINK + https://psmodule.io/Domeneshop/Functions/Get-PSModuleTest/ + + .LINK + https://github.com/PSModule/Domeneshop #> + [OutputType([string])] [CmdletBinding()] - param ( - # Name of the person to greet. + param( + # The name to include in the greeting. [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] [string] $Name ) - Write-Output "Hello, $Name!" + + "Hello, $Name!" } diff --git a/src/functions/public/Invoices/Get-DomeneshopInvoice.ps1 b/src/functions/public/Invoices/Get-DomeneshopInvoice.ps1 new file mode 100644 index 0000000..cc240cb --- /dev/null +++ b/src/functions/public/Invoices/Get-DomeneshopInvoice.ps1 @@ -0,0 +1,67 @@ +function Get-DomeneshopInvoice { + <# + .SYNOPSIS + Gets Domeneshop invoices. + + .DESCRIPTION + Lists invoices, or gets a specific invoice by invoice number. + + .EXAMPLE + Get-DomeneshopInvoice + + List all Domeneshop invoices. + + .EXAMPLE + Get-DomeneshopInvoice -InvoiceID 1001 + + Get invoice 1001. + + .INPUTS + None + + You can't pipe objects to Get-DomeneshopInvoice. + + .OUTPUTS + System.Object + + The matching Domeneshop invoices. + + .NOTES + Uses the default context when Context is omitted. + + .LINK + https://psmodule.io/Domeneshop/Functions/Invoices/Get-DomeneshopInvoice/ + + .LINK + https://api.domeneshop.no/docs/ + #> + [OutputType([object])] + [CmdletBinding(DefaultParameterSetName = 'List')] + param( + # The numeric identifier of a specific invoice. + [Parameter(Mandatory, ParameterSetName = 'Get by ID')] + [ValidateRange(1, [int]::MaxValue)] + [Alias('InvoiceNo')] + [int] $InvoiceID, + + # The stored credential context to use instead of the default. + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Context + ) + + $storedContext = if ($PSBoundParameters.ContainsKey('Context')) { + Get-DomeneshopContext -Context $Context + } else { + Get-DomeneshopContext + } + $resolvedContext = Resolve-DomeneshopContext -Context $storedContext + $apiBaseUri = Get-DomeneshopApiBaseUri -Context $resolvedContext + $uri = if ($PSCmdlet.ParameterSetName -eq 'Get by ID') { + "$apiBaseUri/invoices/$InvoiceID" + } else { + "$apiBaseUri/invoices" + } + + Invoke-DomeneshopApiRequest -Method Get -Uri $uri -Context $resolvedContext +} diff --git a/src/functions/public/Invoices/Invoices.md b/src/functions/public/Invoices/Invoices.md new file mode 100644 index 0000000..dd86a49 --- /dev/null +++ b/src/functions/public/Invoices/Invoices.md @@ -0,0 +1,7 @@ +# Invoices + +Public commands for retrieving Domeneshop invoices. + +## Commands + +- `Get-DomeneshopInvoice`: Lists invoices or gets an invoice by its numeric identifier. diff --git a/tests/Auth.Tests.ps1 b/tests/Auth.Tests.ps1 new file mode 100644 index 0000000..ffa6814 --- /dev/null +++ b/tests/Auth.Tests.ps1 @@ -0,0 +1,192 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester mock parameter filters.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester test setup.' +)] +[CmdletBinding()] +param() + +Describe 'Connect-DomeneshopAccount' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Domeneshop.TestSetup.ps1') + } + + BeforeEach { + Mock Set-Context {} + Mock Get-DomeneshopConfig { [pscustomobject]@{ DefaultContext = $null } } + Mock Set-DomeneshopDefaultContext {} + Mock Get-DomeneshopContext { [pscustomobject]@{ ID = 'demo' } } + Mock Start-Process {} + Mock Read-Host { $script:PromptedSecret } + $script:PromptedSecret = [securestring]::new() + 'prompted-secret'.ToCharArray() | ForEach-Object { $script:PromptedSecret.AppendChar($_) } + } + + It 'stores credentials securely and sets the first context as default' { + $result = Connect-DomeneshopAccount -Token 'token' -Secret 'secret' -Context 'demo' -PassThru + + $result.ID | Should -Be 'demo' + Should -Invoke Set-Context -Times 1 -Exactly -ParameterFilter { + $ID -eq 'demo' -and + $Vault -eq 'Domeneshop' -and + $Context.Secret -is [securestring] + } + Should -Invoke Set-DomeneshopDefaultContext -Times 1 -Exactly -ParameterFilter { + $Context -eq 'demo' -and $Confirm -eq $false + } + } + + It 'does not store credentials when WhatIf is specified' { + Connect-DomeneshopAccount -Token 'token' -Secret 'secret' -Context 'demo' -WhatIf + + Should -Invoke Set-Context -Times 0 -Exactly + Should -Invoke Set-DomeneshopDefaultContext -Times 0 -Exactly + } + + It 'opens API settings and securely prompts when Secret is omitted' { + Connect-DomeneshopAccount -Token 'token' -Context 'demo' + + Should -Invoke Start-Process -Times 1 -Exactly -ParameterFilter { + $FilePath -eq 'https://domene.shop/admin?view=api' + } + Should -Invoke Read-Host -Times 1 -Exactly -ParameterFilter { + $Prompt -eq 'Enter the Domeneshop API secret' -and $AsSecureString + } + Should -Invoke Set-Context -Times 1 -Exactly -ParameterFilter { + $ID -eq 'demo' -and + $Vault -eq 'Domeneshop' -and + [object]::ReferenceEquals($Context.Secret, $script:PromptedSecret) + } + } + + It 'securely prompts and stores credentials when the browser cannot open' { + Mock Start-Process { throw 'No browser is available.' } + Mock Write-Warning {} + + Connect-DomeneshopAccount -Token 'token' -Context 'demo' + + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { + $Message -eq 'Unable to open the Domeneshop API settings page: No browser is available.' + } + Should -Invoke Read-Host -Times 1 -Exactly -ParameterFilter { + $Prompt -eq 'Enter the Domeneshop API secret' -and $AsSecureString + } + Should -Invoke Set-Context -Times 1 -Exactly -ParameterFilter { + [object]::ReferenceEquals($Context.Secret, $script:PromptedSecret) + } + } + + It 'does not open API settings or prompt when WhatIf omits Secret' { + Connect-DomeneshopAccount -Token 'token' -Context 'demo' -WhatIf + + Should -Invoke Start-Process -Times 0 -Exactly + Should -Invoke Read-Host -Times 0 -Exactly + Should -Invoke Set-Context -Times 0 -Exactly + } + + It 'accepts Key as an alias for an explicit secure secret' { + Connect-DomeneshopAccount -Token 'token' -Key $script:PromptedSecret -Context 'demo' + + Should -Invoke Start-Process -Times 0 -Exactly + Should -Invoke Read-Host -Times 0 -Exactly + Should -Invoke Set-Context -Times 1 -Exactly -ParameterFilter { + [object]::ReferenceEquals($Context.Secret, $script:PromptedSecret) + } + } + + It 'rejects unsupported secret types' { + { Connect-DomeneshopAccount -Token 'token' -Secret 42 -Context 'demo' } | + Should -Throw '*Secret must be a SecureString or String value*' + } + + It 'rejects empty string and secure string secrets' { + { Connect-DomeneshopAccount -Token 'token' -Secret ' ' -Context 'demo' } | + Should -Throw '*Secret cannot be empty or whitespace*' + { Connect-DomeneshopAccount -Token 'token' -Secret ([securestring]::new()) -Context 'demo' } | + Should -Throw '*Secret cannot be empty or whitespace*' + + Should -Invoke Set-Context -Times 0 -Exactly + } + + It 'rejects the reserved module configuration context name' { + { Connect-DomeneshopAccount -Token 'token' -Secret 'secret' -Context '__Domeneshop.Config' } | + Should -Throw '*reserved for Domeneshop module configuration*' + + Should -Invoke Set-Context -Times 0 -Exactly + } + + It 'rejects a whitespace-only context name' { + { Connect-DomeneshopAccount -Token 'token' -Secret 'secret' -Context ' ' } | + Should -Throw '*Context name cannot be empty or whitespace*' + + Should -Invoke Set-Context -Times 0 -Exactly + } + + It 'rejects a whitespace-only API token' { + { Connect-DomeneshopAccount -Token ' ' -Secret 'secret' -Context 'demo' } | Should -Throw + + Should -Invoke Set-Context -Times 0 -Exactly + } +} + +Describe 'Get-DomeneshopContext' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Domeneshop.TestSetup.ps1') + } + + It 'gets the configured default context' { + Mock Get-DomeneshopConfig { [pscustomobject]@{ DefaultContext = 'demo' } } + Mock Get-Context { [pscustomobject]@{ ID = 'demo' } } + + $result = Get-DomeneshopContext + + $result.ID | Should -Be 'demo' + Should -Invoke Get-Context -Times 1 -Exactly -ParameterFilter { + $ID -eq 'demo' -and $Vault -eq 'Domeneshop' + } + } + + It 'throws when no default context is configured' { + Mock Get-DomeneshopConfig { [pscustomobject]@{ DefaultContext = '' } } + + { Get-DomeneshopContext } | Should -Throw '*No default Domeneshop context found*' + } + + It 'rejects an explicit request for the reserved module configuration context' { + { Get-DomeneshopContext -Context '__Domeneshop.Config' } | + Should -Throw '*reserved for Domeneshop module configuration*' + } + + It 'throws when the configured default context is missing from the vault' { + Mock Get-DomeneshopConfig { [pscustomobject]@{ DefaultContext = 'missing' } } + Mock Get-Context {} + + { Get-DomeneshopContext } | + Should -Throw '*Domeneshop context*missing*was not found in the Domeneshop vault*' + } + + It 'throws when an explicitly requested context is missing from the vault' { + Mock Get-Context {} + + { Get-DomeneshopContext -Context 'missing' } | + Should -Throw '*Domeneshop context*missing*was not found in the Domeneshop vault*' + } + + It 'excludes the module configuration when listing contexts' { + Mock Get-Context { + @( + [pscustomobject]@{ ID = '__Domeneshop.Config' } + [pscustomobject]@{ ID = 'demo' } + ) + } + + $result = Get-DomeneshopContext -ListAvailable + + $result.ID | Should -Be 'demo' + } +} diff --git a/tests/Ddns.Tests.ps1 b/tests/Ddns.Tests.ps1 new file mode 100644 index 0000000..597d42e --- /dev/null +++ b/tests/Ddns.Tests.ps1 @@ -0,0 +1,45 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester mock parameter filters.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester test setup.' +)] +[CmdletBinding()] +param() + +Describe 'Update-DomeneshopDdns' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Domeneshop.TestSetup.ps1') + } + + BeforeEach { + Mock Get-DomeneshopContext { $script:DomeneshopTestContext } + Mock Invoke-DomeneshopApiRequest {} + } + + It 'builds an escaped hostname and IP query' { + Update-DomeneshopDdns -Context 'demo' -Hostname 'home example.com' -MyIP '192.0.2.10' + + Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Get' -and + $Uri -eq 'https://api.domeneshop.no/v0/dyndns/update?hostname=home%20example.com&myip=192.0.2.10' + } + } + + It 'does not send a request when WhatIf is specified' { + Update-DomeneshopDdns -Context 'demo' -Hostname 'home.example.com' -WhatIf + + Should -Invoke Invoke-DomeneshopApiRequest -Times 0 -Exactly + } + + It 'rejects whitespace-only hostnames and IP addresses' { + { Update-DomeneshopDdns -Context 'demo' -Hostname ' ' } | Should -Throw + { Update-DomeneshopDdns -Context 'demo' -Hostname 'home.example.com' -MyIP ' ' } | Should -Throw + + Should -Invoke Invoke-DomeneshopApiRequest -Times 0 -Exactly + } +} diff --git a/tests/Dns.Tests.ps1 b/tests/Dns.Tests.ps1 new file mode 100644 index 0000000..18b7c4b --- /dev/null +++ b/tests/Dns.Tests.ps1 @@ -0,0 +1,132 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester mock parameter filters.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester test setup.' +)] +[CmdletBinding()] +param() + +Describe 'Add-DomeneshopDnsRecord' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Domeneshop.TestSetup.ps1') + } + + BeforeEach { + Mock Get-DomeneshopContext { $script:DomeneshopTestContext } + Mock Invoke-DomeneshopApiRequest {} + $script:Record = @{ host = 'www'; type = 'A'; data = '192.0.2.10' } + } + + It 'posts the DNS record to the domain endpoint' { + Add-DomeneshopDnsRecord -Context 'demo' -DomainID 42 -Record $script:Record + + Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Post' -and + $Uri -eq 'https://api.domeneshop.no/v0/domains/42/dns' -and + $Body -eq $script:Record + } + } + + It 'does not send a request when WhatIf is specified' { + Add-DomeneshopDnsRecord -Context 'demo' -DomainID 42 -Record $script:Record -WhatIf + + Should -Invoke Invoke-DomeneshopApiRequest -Times 0 -Exactly + } +} + +Describe 'Get-DomeneshopDnsRecord' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Domeneshop.TestSetup.ps1') + } + + BeforeEach { + Mock Get-DomeneshopContext { $script:DomeneshopTestContext } + Mock Invoke-DomeneshopApiRequest {} + } + + It 'builds an escaped list query' { + Get-DomeneshopDnsRecord -Context 'demo' -DomainID 42 -RecordHost 'home office' -Type 'A' -Data '192.0.2.10' + + Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Get' -and + $Uri -eq 'https://api.domeneshop.no/v0/domains/42/dns?host=home%20office&type=A&data=192.0.2.10' + } + } + + It 'gets a DNS record by ID' { + Get-DomeneshopDnsRecord -Context 'demo' -DomainID 42 -RecordID 7 + + Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Get' -and + $Uri -eq 'https://api.domeneshop.no/v0/domains/42/dns/7' + } + } + + It 'rejects whitespace-only list filters' { + { Get-DomeneshopDnsRecord -Context 'demo' -DomainID 42 -RecordHost ' ' } | Should -Throw + { Get-DomeneshopDnsRecord -Context 'demo' -DomainID 42 -Type ' ' } | Should -Throw + { Get-DomeneshopDnsRecord -Context 'demo' -DomainID 42 -Data ' ' } | Should -Throw + + Should -Invoke Invoke-DomeneshopApiRequest -Times 0 -Exactly + } +} + +Describe 'Remove-DomeneshopDnsRecord' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Domeneshop.TestSetup.ps1') + } + + BeforeEach { + Mock Get-DomeneshopContext { $script:DomeneshopTestContext } + Mock Invoke-DomeneshopApiRequest {} + } + + It 'deletes the DNS record endpoint' { + Remove-DomeneshopDnsRecord -Context 'demo' -DomainID 42 -RecordID 7 -Confirm:$false + + Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Delete' -and + $Uri -eq 'https://api.domeneshop.no/v0/domains/42/dns/7' + } + } + + It 'does not send a request when WhatIf is specified' { + Remove-DomeneshopDnsRecord -Context 'demo' -DomainID 42 -RecordID 7 -WhatIf + + Should -Invoke Invoke-DomeneshopApiRequest -Times 0 -Exactly + } +} + +Describe 'Set-DomeneshopDnsRecord' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Domeneshop.TestSetup.ps1') + } + + BeforeEach { + Mock Get-DomeneshopContext { $script:DomeneshopTestContext } + Mock Invoke-DomeneshopApiRequest {} + $script:Record = @{ host = 'www'; type = 'A'; data = '192.0.2.20' } + } + + It 'puts the replacement record to the record endpoint' { + Set-DomeneshopDnsRecord -Context 'demo' -DomainID 42 -RecordID 7 -Record $script:Record + + Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Put' -and + $Uri -eq 'https://api.domeneshop.no/v0/domains/42/dns/7' -and + $Body -eq $script:Record + } + } + + It 'does not send a request when WhatIf is specified' { + Set-DomeneshopDnsRecord -Context 'demo' -DomainID 42 -RecordID 7 -Record $script:Record -WhatIf + + Should -Invoke Invoke-DomeneshopApiRequest -Times 0 -Exactly + } +} + diff --git a/tests/Domains.Tests.ps1 b/tests/Domains.Tests.ps1 new file mode 100644 index 0000000..deb77f4 --- /dev/null +++ b/tests/Domains.Tests.ps1 @@ -0,0 +1,51 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester mock parameter filters.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester test setup.' +)] +[CmdletBinding()] +param() + +Describe 'Get-DomeneshopDomain' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Domeneshop.TestSetup.ps1') + } + + BeforeEach { + Mock Get-DomeneshopContext { $script:DomeneshopTestContext } + Mock Invoke-DomeneshopApiRequest {} + } + + It 'adds an escaped domain filter to list requests' { + Get-DomeneshopDomain -Context 'demo' -Domain 'example & test' + + Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Get' -and + $Uri -eq 'https://api.domeneshop.no/v0/domains?domain=example%20%26%20test' -and + $Context.ID -eq 'demo' + } + } + + It 'gets a domain by ID' { + Get-DomeneshopDomain -Context 'demo' -DomainID 42 + + Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Get' -and $Uri -eq 'https://api.domeneshop.no/v0/domains/42' + } + } + + It 'rejects non-positive domain IDs' { + { Get-DomeneshopDomain -Context 'demo' -DomainID 0 } | Should -Throw + } + + It 'rejects whitespace-only domain filters' { + { Get-DomeneshopDomain -Context 'demo' -Domain ' ' } | Should -Throw + + Should -Invoke Invoke-DomeneshopApiRequest -Times 0 -Exactly + } +} diff --git a/tests/Domeneshop.Private.Tests.ps1 b/tests/Domeneshop.Private.Tests.ps1 new file mode 100644 index 0000000..47e8ace --- /dev/null +++ b/tests/Domeneshop.Private.Tests.ps1 @@ -0,0 +1,75 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester mock parameter filters.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester test setup.' +)] +[CmdletBinding()] +param() + +Describe 'Domeneshop private helpers' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Domeneshop.TestSetup.ps1') + } + + It 'rejects a missing resolved context' { + { Resolve-DomeneshopContext -Context $null } | + Should -Throw '*No Domeneshop context found*' + } + + It 'rejects a context without secure credentials' { + $context = [pscustomobject]@{ + ID = 'invalid' + Token = 'token' + Secret = 'plain-text' + ApiBaseUri = 'https://api.domeneshop.no/v0' + } + + { Resolve-DomeneshopContext -Context $context } | + Should -Throw '*missing valid credentials*' + } + + It 'rejects an invalid API base URI' { + $context = [pscustomobject]@{ + ID = 'invalid' + ApiBaseUri = 'not a URI' + } + + { Get-DomeneshopApiBaseUri -Context $context } | + Should -Throw '*invalid API base URI*' + } + + It 'rejects the reserved module configuration name as a default context' { + { Set-DomeneshopDefaultContext -Context '__Domeneshop.Config' -Confirm:$false } | + Should -Throw '*reserved for Domeneshop module configuration*' + } + + It 'does not mutate the configuration when setting the default with WhatIf' { + $config = [pscustomobject]@{ DefaultContext = 'original' } + Mock Get-DomeneshopConfig { $config } + Mock Set-Context {} + + Set-DomeneshopDefaultContext -Context 'replacement' -WhatIf + + $config.DefaultContext | Should -Be 'original' + Should -Invoke Set-Context -Times 0 -Exactly + } + + It 'uses basic authentication and terminating transport errors' { + Mock Invoke-RestMethod { [pscustomobject]@{ ok = $true } } + + $result = Invoke-DomeneshopApiRequest -Method Get -Uri 'https://api.domeneshop.no/v0/domains' -Context $script:DomeneshopTestContext + + $result.ok | Should -BeTrue + Should -Invoke Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Get' -and + $Authentication -eq 'Basic' -and + $Credential.UserName -eq 'token' -and + $ErrorAction -eq 'Stop' + } + } +} diff --git a/tests/Domeneshop.TestSetup.ps1 b/tests/Domeneshop.TestSetup.ps1 new file mode 100644 index 0000000..f5bafba --- /dev/null +++ b/tests/Domeneshop.TestSetup.ps1 @@ -0,0 +1,21 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'The fixed local test credential never leaves the mocked request boundary.' +)] +[CmdletBinding()] +param() + +$sourcePath = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'src' +$functionsPath = Join-Path -Path $sourcePath -ChildPath 'functions' +$functionFiles = Get-ChildItem -Path $functionsPath -Filter '*.ps1' -Recurse -File | + Sort-Object -Property FullName +foreach ($file in $functionFiles) { + . $file.FullName +} + +$script:DomeneshopTestContext = [pscustomobject]@{ + ID = 'demo' + Token = 'token' + Secret = ConvertTo-SecureString -AsPlainText 'secret' -Force + ApiBaseUri = 'https://api.domeneshop.no/v0' +} diff --git a/tests/Forwards.Tests.ps1 b/tests/Forwards.Tests.ps1 new file mode 100644 index 0000000..815151a --- /dev/null +++ b/tests/Forwards.Tests.ps1 @@ -0,0 +1,144 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester mock parameter filters.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester test setup.' +)] +[CmdletBinding()] +param() + +Describe 'Add-DomeneshopForward' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Domeneshop.TestSetup.ps1') + } + + BeforeEach { + Mock Get-DomeneshopContext { $script:DomeneshopTestContext } + Mock Invoke-DomeneshopApiRequest {} + $script:Forward = @{ host = 'www'; url = 'https://example.net' } + } + + It 'posts the forward to the domain endpoint' { + Add-DomeneshopForward -Context 'demo' -DomainID 42 -Forward $script:Forward + + Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Post' -and + $Uri -eq 'https://api.domeneshop.no/v0/domains/42/forwards/' -and + $Body -eq $script:Forward + } + } + + It 'does not send a request when WhatIf is specified' { + Add-DomeneshopForward -Context 'demo' -DomainID 42 -Forward $script:Forward -WhatIf + + Should -Invoke Invoke-DomeneshopApiRequest -Times 0 -Exactly + } +} + +Describe 'Get-DomeneshopForward' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Domeneshop.TestSetup.ps1') + } + + BeforeEach { + Mock Get-DomeneshopContext { $script:DomeneshopTestContext } + Mock Invoke-DomeneshopApiRequest {} + } + + It 'lists forwards for a domain' { + Get-DomeneshopForward -Context 'demo' -DomainID 42 + + Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Get' -and + $Uri -eq 'https://api.domeneshop.no/v0/domains/42/forwards/' + } + } + + It 'gets a forward by escaped host name' { + Get-DomeneshopForward -Context 'demo' -DomainID 42 -ForwardHost 'home office' + + Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Get' -and + $Uri -eq 'https://api.domeneshop.no/v0/domains/42/forwards/home%20office' + } + } + + It 'rejects whitespace-only forward hosts' { + { Get-DomeneshopForward -Context 'demo' -DomainID 42 -ForwardHost ' ' } | Should -Throw + + Should -Invoke Invoke-DomeneshopApiRequest -Times 0 -Exactly + } +} + +Describe 'Remove-DomeneshopForward' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Domeneshop.TestSetup.ps1') + } + + BeforeEach { + Mock Get-DomeneshopContext { $script:DomeneshopTestContext } + Mock Invoke-DomeneshopApiRequest {} + } + + It 'deletes the host endpoint' { + Remove-DomeneshopForward -Context 'demo' -DomainID 42 -ForwardHost 'www' -Confirm:$false + + Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Delete' -and + $Uri -eq 'https://api.domeneshop.no/v0/domains/42/forwards/www' + } + } + + It 'does not send a request when WhatIf is specified' { + Remove-DomeneshopForward -Context 'demo' -DomainID 42 -ForwardHost 'www' -WhatIf + + Should -Invoke Invoke-DomeneshopApiRequest -Times 0 -Exactly + } + + It 'rejects whitespace-only forward hosts' { + { Remove-DomeneshopForward -Context 'demo' -DomainID 42 -ForwardHost ' ' -Confirm:$false } | Should -Throw + + Should -Invoke Invoke-DomeneshopApiRequest -Times 0 -Exactly + } +} + +Describe 'Set-DomeneshopForward' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Domeneshop.TestSetup.ps1') + } + + BeforeEach { + Mock Get-DomeneshopContext { $script:DomeneshopTestContext } + Mock Invoke-DomeneshopApiRequest {} + $script:Forward = @{ host = 'www'; url = 'https://example.org' } + } + + It 'puts the replacement forward to the host endpoint' { + Set-DomeneshopForward -Context 'demo' -DomainID 42 -ForwardHost 'www' -Forward $script:Forward + + Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Put' -and + $Uri -eq 'https://api.domeneshop.no/v0/domains/42/forwards/www' -and + $Body -eq $script:Forward + } + } + + It 'does not send a request when WhatIf is specified' { + Set-DomeneshopForward -Context 'demo' -DomainID 42 -ForwardHost 'www' -Forward $script:Forward -WhatIf + + Should -Invoke Invoke-DomeneshopApiRequest -Times 0 -Exactly + } + + It 'rejects whitespace-only forward hosts' { + { + Set-DomeneshopForward -Context 'demo' -DomainID 42 -ForwardHost ' ' -Forward $script:Forward + } | Should -Throw + + Should -Invoke Invoke-DomeneshopApiRequest -Times 0 -Exactly + } +} + diff --git a/tests/Get-PSModuleTest.Tests.ps1 b/tests/Get-PSModuleTest.Tests.ps1 new file mode 100644 index 0000000..557df8c --- /dev/null +++ b/tests/Get-PSModuleTest.Tests.ps1 @@ -0,0 +1,26 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[CmdletBinding()] +param() + +Describe 'Get-PSModuleTest' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Domeneshop.TestSetup.ps1') + } + + It 'returns a greeting for the supplied name' { + Get-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' + } + + It 'rejects a whitespace-only name' { + { Get-PSModuleTest -Name ' ' } | Should -Throw + } +} diff --git a/tests/Invoices.Tests.ps1 b/tests/Invoices.Tests.ps1 new file mode 100644 index 0000000..e879b5c --- /dev/null +++ b/tests/Invoices.Tests.ps1 @@ -0,0 +1,39 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester mock parameter filters.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester test setup.' +)] +[CmdletBinding()] +param() + +Describe 'Get-DomeneshopInvoice' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Domeneshop.TestSetup.ps1') + } + + BeforeEach { + Mock Get-DomeneshopContext { $script:DomeneshopTestContext } + Mock Invoke-DomeneshopApiRequest {} + } + + It 'lists invoices' { + Get-DomeneshopInvoice -Context 'demo' + + Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Get' -and $Uri -eq 'https://api.domeneshop.no/v0/invoices' + } + } + + It 'gets an invoice by ID' { + Get-DomeneshopInvoice -Context 'demo' -InvoiceID 1001 + + Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'Get' -and $Uri -eq 'https://api.domeneshop.no/v0/invoices/1001' + } + } +} diff --git a/tests/ModuleRequirements.Tests.ps1 b/tests/ModuleRequirements.Tests.ps1 new file mode 100644 index 0000000..66b153c --- /dev/null +++ b/tests/ModuleRequirements.Tests.ps1 @@ -0,0 +1,29 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester test discovery.' +)] +[CmdletBinding()] +param() + +$sourcePath = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'src' +$testCases = @( + @{ + RequiredVersions = @( + Get-ChildItem -Path $sourcePath -Filter '*.ps1' -Recurse -File | + Select-String -Pattern '^\s*#Requires\s+-Version\s+(?\S+)\s*$' | + ForEach-Object { $_.Matches[0].Groups['Version'].Value } | + Sort-Object -Unique + ) + } +) + +Describe 'Module requirements' { + It 'targets only the latest PowerShell LTS' -ForEach $testCases { + param($RequiredVersions) + + $RequiredVersions | Should -HaveCount 1 + $RequiredVersions[0] | Should -Be '7.6' + } +} diff --git a/tests/PSModuleTest.Tests.ps1 b/tests/PSModuleTest.Tests.ps1 deleted file mode 100644 index 8258bb7..0000000 --- a/tests/PSModuleTest.Tests.ps1 +++ /dev/null @@ -1,16 +0,0 @@ -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSReviewUnusedParameter', '', - Justification = 'Required for Pester tests' -)] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', '', - Justification = 'Required for Pester tests' -)] -[CmdletBinding()] -param() - -Describe 'Module' { - It 'Function: Get-PSModuleTest' { - Get-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' - } -} diff --git a/tests/PublicHelpLinks.Tests.ps1 b/tests/PublicHelpLinks.Tests.ps1 new file mode 100644 index 0000000..c97247f --- /dev/null +++ b/tests/PublicHelpLinks.Tests.ps1 @@ -0,0 +1,41 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester test discovery.' +)] +[CmdletBinding()] +param() + +$sourcePath = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'src' +$publicFunctionsPath = Join-Path -Path $sourcePath -ChildPath 'functions' +$publicFunctionsPath = Join-Path -Path $publicFunctionsPath -ChildPath 'public' +$testCases = Get-ChildItem -Path $publicFunctionsPath -Filter '*.ps1' -Recurse -File | + Sort-Object -Property FullName | + ForEach-Object { + $relativePath = [IO.Path]::GetRelativePath($publicFunctionsPath, $_.FullName) + $relativeDirectory = Split-Path -Path $relativePath -Parent + $documentationPath = if ($relativeDirectory) { + '{0}/{1}' -f ($relativeDirectory -replace '[\\/]', '/'), $_.BaseName + } else { + $_.BaseName + } + + @{ + DocumentationPath = $documentationPath + Path = $_.FullName + } + } + +Describe 'Public function help links' { + It 'puts the canonical documentation link first for ' -ForEach $testCases { + param($DocumentationPath, $Path) + + $content = Get-Content -Path $Path -Raw + $links = [regex]::Matches($content, '(?ms)^\s*\.LINK\s*\r?\n\s*(?\S+)') + + $links.Count | Should -BeGreaterThan 0 + $links[0].Groups['Uri'].Value | + Should -Be "https://psmodule.io/Domeneshop/Functions/$DocumentationPath/" + } +} diff --git a/tests/TestLayout.Tests.ps1 b/tests/TestLayout.Tests.ps1 new file mode 100644 index 0000000..28045e7 --- /dev/null +++ b/tests/TestLayout.Tests.ps1 @@ -0,0 +1,30 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[CmdletBinding()] +param() + +$sourcePath = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'src' +$publicFunctionsPath = Join-Path -Path $sourcePath -ChildPath 'functions/public' +$testCases = Get-ChildItem -Path $publicFunctionsPath -Filter '*.ps1' -Recurse | + ForEach-Object { + $relativePath = [IO.Path]::GetRelativePath($publicFunctionsPath, $_.FullName) + $group = Split-Path -Path $relativePath -Parent + $testFile = if ($group) { + "$group.Tests.ps1" + } else { + "$($_.BaseName).Tests.ps1" + } + @{ + FunctionName = $_.BaseName + TestFile = $testFile + TestPath = Join-Path -Path $PSScriptRoot -ChildPath $testFile + } + } + +Describe 'Public command test layout' { + It 'covers in ' -ForEach $testCases { + Test-Path -LiteralPath $TestPath -PathType Leaf | Should -BeTrue + Get-Content -LiteralPath $TestPath -Raw | + Should -Match ([regex]::Escape("Describe '$FunctionName'")) + } +}