From 0b812c052270131e69333a8278105f445a5fa911 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 25 Jul 2026 18:05:38 +0200 Subject: [PATCH 1/4] Harden Domeneshop command contracts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Auth/Get-DomeneshopConfig.ps1 | 37 +++++++++++-- .../Auth/Resolve-DomeneshopContext.ps1 | 51 ++++++++++++++---- .../Auth/Set-DomeneshopDefaultContext.ps1 | 39 +++++++++++++- .../private/Get-DomeneshopApiBaseUri.ps1 | 42 +++++++++++++-- .../private/Invoke-DomeneshopApiRequest.ps1 | 44 +++++++++++++++- .../public/Auth/Connect-DomeneshopAccount.ps1 | 50 +++++++++++++++--- .../public/Auth/Get-DomeneshopContext.ps1 | 52 ++++++++++++++----- .../public/Ddns/Update-DomeneshopDdns.ps1 | 46 ++++++++++++++-- .../public/Dns/Add-DomeneshopDnsRecord.ps1 | 45 ++++++++++++++-- .../public/Dns/Get-DomeneshopDnsRecord.ps1 | 49 +++++++++++++++-- .../public/Dns/Remove-DomeneshopDnsRecord.ps1 | 39 +++++++++++++- .../public/Dns/Set-DomeneshopDnsRecord.ps1 | 47 +++++++++++++++-- .../public/Domains/Get-DomeneshopDomain.ps1 | 35 +++++++++++-- .../public/Forwards/Add-DomeneshopForward.ps1 | 45 ++++++++++++++-- .../public/Forwards/Get-DomeneshopForward.ps1 | 43 +++++++++++++-- .../Forwards/Remove-DomeneshopForward.ps1 | 39 +++++++++++++- .../public/Forwards/Set-DomeneshopForward.ps1 | 47 +++++++++++++++-- .../public/Invoices/Get-DomeneshopInvoice.ps1 | 41 +++++++++++++-- tests/DomeneshopContext.Tests.ps1 | 16 +++--- 19 files changed, 722 insertions(+), 85 deletions(-) diff --git a/src/functions/private/Auth/Get-DomeneshopConfig.ps1 b/src/functions/private/Auth/Get-DomeneshopConfig.ps1 index 4e51fd2..0c0439f 100644 --- a/src/functions/private/Auth/Get-DomeneshopConfig.ps1 +++ b/src/functions/private/Auth/Get-DomeneshopConfig.ps1 @@ -1,18 +1,47 @@ +#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/Context/ + #> + [OutputType([object])] [CmdletBinding()] param() $vault = 'Domeneshop' $id = '__Domeneshop.Config' - $config = Get-Context -ID $id -Vault $vault -ErrorAction SilentlyContinue + $config = Get-Context -ID $id -Vault $vault if (-not $config) { - $config = [ordered]@{ + $config = [pscustomobject][ordered]@{ DefaultContext = $null ApiBaseUri = 'https://api.domeneshop.no/v0' } - Set-Context -ID $id -Vault $vault -Context $config - $config = Get-Context -ID $id -Vault $vault } $config diff --git a/src/functions/private/Auth/Resolve-DomeneshopContext.ps1 b/src/functions/private/Auth/Resolve-DomeneshopContext.ps1 index e3aeca2..4042416 100644 --- a/src/functions/private/Auth/Resolve-DomeneshopContext.ps1 +++ b/src/functions/private/Auth/Resolve-DomeneshopContext.ps1 @@ -1,23 +1,52 @@ 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/Context/ + #> + [OutputType([object])] [CmdletBinding()] param( - [Parameter()] - [string] $Context + # The context object selected by a public command. + [Parameter(Mandatory)] + [AllowNull()] + [object] $Context ) - $resolvedContext = if ($Context) { - Get-DomeneshopContext -Context $Context - } else { - Get-DomeneshopContext + if ($null -eq $Context) { + throw "No Domeneshop context found. Run 'Connect-DomeneshopAccount' first." } - if (-not $resolvedContext) { - 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]::IsNullOrEmpty($resolvedContext.Token) -or -not ($resolvedContext.Secret -is [securestring])) { - throw "The Domeneshop context [$($resolvedContext.ID)] is missing valid credentials." + if ([string]::IsNullOrWhiteSpace([string] $Context.ApiBaseUri)) { + throw "The Domeneshop context [$($Context.ID)] is missing an API base URI." } - $resolvedContext + $Context } diff --git a/src/functions/private/Auth/Set-DomeneshopDefaultContext.ps1 b/src/functions/private/Auth/Set-DomeneshopDefaultContext.ps1 index a256e6e..bfadda4 100644 --- a/src/functions/private/Auth/Set-DomeneshopDefaultContext.ps1 +++ b/src/functions/private/Auth/Set-DomeneshopDefaultContext.ps1 @@ -1,11 +1,46 @@ +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } + function Set-DomeneshopDefaultContext { - [CmdletBinding()] + <# + .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 + The public caller is responsible for guarding this mutation with ShouldProcess. + + .LINK + https://psmodule.io/Context/ + #> + [OutputType([void])] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] param( + # The name of an existing Domeneshop context. [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $Context ) $config = Get-DomeneshopConfig $config.DefaultContext = $Context - Set-Context -ID '__Domeneshop.Config' -Vault 'Domeneshop' -Context $config + if ($PSCmdlet.ShouldProcess('Domeneshop module configuration', "Set [$Context] as the default context")) { + $null = Set-Context -ID '__Domeneshop.Config' -Vault 'Domeneshop' -Context $config + } } diff --git a/src/functions/private/Get-DomeneshopApiBaseUri.ps1 b/src/functions/private/Get-DomeneshopApiBaseUri.ps1 index 8c538a0..e8a58b3 100644 --- a/src/functions/private/Get-DomeneshopApiBaseUri.ps1 +++ b/src/functions/private/Get-DomeneshopApiBaseUri.ps1 @@ -1,14 +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]::IsNullOrEmpty($apiBaseUri)) { - $apiBaseUri = 'https://api.domeneshop.no/v0' + 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." } - $apiBaseUri.TrimEnd('/') + $parsedUri.AbsoluteUri.TrimEnd('/') } diff --git a/src/functions/private/Invoke-DomeneshopApiRequest.ps1 b/src/functions/private/Invoke-DomeneshopApiRequest.ps1 index cbc1baa..3cfd191 100644 --- a/src/functions/private/Invoke-DomeneshopApiRequest.ps1 +++ b/src/functions/private/Invoke-DomeneshopApiRequest.ps1 @@ -1,16 +1,56 @@ 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)] - [string] $Uri, + [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 @@ -26,6 +66,7 @@ function Invoke-DomeneshopApiRequest { Uri = $Uri Authentication = 'Basic' Credential = $credential + ErrorAction = 'Stop' } if ($PSBoundParameters.ContainsKey('Body')) { @@ -33,5 +74,6 @@ function Invoke-DomeneshopApiRequest { $params['Body'] = ($Body | ConvertTo-Json -Depth 100) } + Write-Debug "Sending $Method request to [$Uri]." Invoke-RestMethod @params } diff --git a/src/functions/public/Auth/Connect-DomeneshopAccount.ps1 b/src/functions/public/Auth/Connect-DomeneshopAccount.ps1 index 0a1ad41..9e563dd 100644 --- a/src/functions/public/Auth/Connect-DomeneshopAccount.ps1 +++ b/src/functions/public/Auth/Connect-DomeneshopAccount.ps1 @@ -1,3 +1,5 @@ +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } + function Connect-DomeneshopAccount { <# .SYNOPSIS @@ -8,22 +10,52 @@ function Connect-DomeneshopAccount { .EXAMPLE Connect-DomeneshopAccount -Token 'my-token' -Secret (Read-Host -AsSecureString) + + Store credentials in the default named context. + + .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://api.domeneshop.no/docs/ #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'String secrets remain supported for compatibility and are converted before storage.' + )] [OutputType([object])] - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess)] param( + # The Domeneshop API token used as the Basic authentication username. [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [string] $Token, + # The Domeneshop API secret as a string or secure string. [Parameter(Mandatory)] + [ValidateNotNull()] [object] $Secret, + # The name used to store and retrieve this credential context. [Parameter()] + [ValidateNotNullOrEmpty()] [string] $Context = 'default', + # Set this context as the default for commands that omit Context. [Parameter()] [switch] $Default, + # Emit the stored context after it is saved. [Parameter()] [switch] $PassThru ) @@ -43,14 +75,16 @@ function Connect-DomeneshopAccount { ConnectedAt = Get-Date } - Set-Context -ID $Context -Vault 'Domeneshop' -Context $contextObject + if ($PSCmdlet.ShouldProcess("Domeneshop context [$Context]", 'Store API credentials')) { + $null = Set-Context -ID $Context -Vault 'Domeneshop' -Context $contextObject - $config = Get-DomeneshopConfig - if ($Default -or [string]::IsNullOrEmpty($config.DefaultContext)) { - Set-DomeneshopDefaultContext -Context $Context - } + $config = Get-DomeneshopConfig + if ($Default -or [string]::IsNullOrWhiteSpace([string] $config.DefaultContext)) { + Set-DomeneshopDefaultContext -Context $Context + } - if ($PassThru) { - Get-DomeneshopContext -Context $Context + if ($PassThru) { + Get-DomeneshopContext -Context $Context + } } } diff --git a/src/functions/public/Auth/Get-DomeneshopContext.ps1 b/src/functions/public/Auth/Get-DomeneshopContext.ps1 index 0bd6741..4064e5d 100644 --- a/src/functions/public/Auth/Get-DomeneshopContext.ps1 +++ b/src/functions/public/Auth/Get-DomeneshopContext.ps1 @@ -1,3 +1,5 @@ +#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } + function Get-DomeneshopContext { <# .SYNOPSIS @@ -8,27 +10,53 @@ function Get-DomeneshopContext { .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/Context/ #> [OutputType([object])] - [CmdletBinding(DefaultParameterSetName = 'GetDefault')] + [CmdletBinding(DefaultParameterSetName = 'Get default')] param( - [Parameter(Mandatory, ParameterSetName = 'GetNamed')] + # The name of a specific stored context. + [Parameter(Mandatory, ParameterSetName = 'Get named')] + [ValidateNotNullOrEmpty()] [string] $Context, - [Parameter(Mandatory, ParameterSetName = 'ListAvailable')] + # List every stored Domeneshop credential context. + [Parameter(Mandatory, ParameterSetName = 'List available')] [switch] $ListAvailable ) - $id = switch ($PSCmdlet.ParameterSetName) { - 'GetNamed' { $Context; break } - 'ListAvailable' { '*'; break } - default { - $config = Get-DomeneshopConfig - if ([string]::IsNullOrEmpty($config.DefaultContext)) { - throw "No default Domeneshop context found. Run 'Connect-DomeneshopAccount' first." - } - $config.DefaultContext + 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 } Get-Context -ID $id -Vault 'Domeneshop' | Where-Object { $_.ID -ne '__Domeneshop.Config' } | Sort-Object -Property ID diff --git a/src/functions/public/Ddns/Update-DomeneshopDdns.ps1 b/src/functions/public/Ddns/Update-DomeneshopDdns.ps1 index ea5970b..40c592c 100644 --- a/src/functions/public/Ddns/Update-DomeneshopDdns.ps1 +++ b/src/functions/public/Ddns/Update-DomeneshopDdns.ps1 @@ -1,32 +1,70 @@ function Update-DomeneshopDdns { <# .SYNOPSIS - Updates dynamic DNS for a hostname. + 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://api.domeneshop.no/docs/ #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseSingularNouns', '', + Justification = 'DDNS is the established singular name of the Domeneshop API capability.' + )] [OutputType([object])] - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess)] param( + # The fully qualified host name to update. [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [Alias('Host')] [string] $Hostname, + # The IPv4 or IPv6 address to publish. [Parameter()] + [ValidateNotNullOrEmpty()] [Alias('IP')] [string] $MyIP, + # The stored credential context to use instead of the default. [Parameter()] + [ValidateNotNullOrEmpty()] [string] $Context ) - $resolvedContext = Resolve-DomeneshopContext -Context $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))" } - Invoke-DomeneshopApiRequest -Method Get -Uri $uri -Context $resolvedContext + 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 index 0277ae1..38685b0 100644 --- a/src/functions/public/Dns/Add-DomeneshopDnsRecord.ps1 +++ b/src/functions/public/Dns/Add-DomeneshopDnsRecord.ps1 @@ -1,24 +1,61 @@ function Add-DomeneshopDnsRecord { <# .SYNOPSIS - Adds a DNS record to a Domeneshop domain. + 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://api.domeneshop.no/docs/ #> [OutputType([object])] - [CmdletBinding()] + [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 ) - $resolvedContext = Resolve-DomeneshopContext -Context $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" - Invoke-DomeneshopApiRequest -Method Post -Uri $uri -Context $resolvedContext -Body $Record + 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/Get-DomeneshopDnsRecord.ps1 b/src/functions/public/Dns/Get-DomeneshopDnsRecord.ps1 index d09e6e7..557aa50 100644 --- a/src/functions/public/Dns/Get-DomeneshopDnsRecord.ps1 +++ b/src/functions/public/Dns/Get-DomeneshopDnsRecord.ps1 @@ -5,35 +5,78 @@ function Get-DomeneshopDnsRecord { .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://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, - [Parameter(Mandatory, ParameterSetName = 'GetByID')] + # The numeric identifier of a specific DNS record. + [Parameter(Mandatory, ParameterSetName = 'Get by ID')] + [ValidateRange(1, [int]::MaxValue)] [int] $RecordID, + # A host-name filter for list requests. [Parameter(ParameterSetName = 'List')] + [ValidateNotNullOrEmpty()] [Alias('Host')] [string] $RecordHost, + # A DNS record type filter for list requests. [Parameter(ParameterSetName = 'List')] + [ValidateNotNullOrEmpty()] [string] $Type, + # A record-data filter for list requests. [Parameter(ParameterSetName = 'List')] + [ValidateNotNullOrEmpty()] [string] $Data, + # The stored credential context to use instead of the default. [Parameter()] + [ValidateNotNullOrEmpty()] [string] $Context ) - $resolvedContext = Resolve-DomeneshopContext -Context $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 'GetByID') { + if ($PSCmdlet.ParameterSetName -eq 'Get by ID') { $uri = "$uri/$RecordID" } else { $queryParts = @() diff --git a/src/functions/public/Dns/Remove-DomeneshopDnsRecord.ps1 b/src/functions/public/Dns/Remove-DomeneshopDnsRecord.ps1 index 987476b..d41bd2a 100644 --- a/src/functions/public/Dns/Remove-DomeneshopDnsRecord.ps1 +++ b/src/functions/public/Dns/Remove-DomeneshopDnsRecord.ps1 @@ -1,22 +1,57 @@ function Remove-DomeneshopDnsRecord { <# .SYNOPSIS - Removes a DNS record from a Domeneshop domain. + 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://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 ) - $resolvedContext = Resolve-DomeneshopContext -Context $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" diff --git a/src/functions/public/Dns/Set-DomeneshopDnsRecord.ps1 b/src/functions/public/Dns/Set-DomeneshopDnsRecord.ps1 index 6c13961..b4b2c6f 100644 --- a/src/functions/public/Dns/Set-DomeneshopDnsRecord.ps1 +++ b/src/functions/public/Dns/Set-DomeneshopDnsRecord.ps1 @@ -1,27 +1,66 @@ function Set-DomeneshopDnsRecord { <# .SYNOPSIS - Updates a DNS record on a Domeneshop domain. + 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://api.domeneshop.no/docs/ #> [OutputType([object])] - [CmdletBinding()] + [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 ) - $resolvedContext = Resolve-DomeneshopContext -Context $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" - Invoke-DomeneshopApiRequest -Method Put -Uri $uri -Context $resolvedContext -Body $Record + 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/Get-DomeneshopDomain.ps1 b/src/functions/public/Domains/Get-DomeneshopDomain.ps1 index be1ba64..588616d 100644 --- a/src/functions/public/Domains/Get-DomeneshopDomain.ps1 +++ b/src/functions/public/Domains/Get-DomeneshopDomain.ps1 @@ -11,25 +11,54 @@ function 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://api.domeneshop.no/docs/ #> [OutputType([object[]])] [CmdletBinding(DefaultParameterSetName = 'List')] param( + # A domain-name filter for list requests. [Parameter(ParameterSetName = 'List')] + [ValidateNotNullOrEmpty()] [string] $Domain, - [Parameter(Mandatory, ParameterSetName = 'GetByID')] + # 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 ) - $resolvedContext = Resolve-DomeneshopContext -Context $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) { - 'GetByID' { "$apiBaseUri/domains/$DomainID"; break } + 'Get by ID' { "$apiBaseUri/domains/$DomainID"; break } default { $listUri = "$apiBaseUri/domains" if ($Domain) { diff --git a/src/functions/public/Forwards/Add-DomeneshopForward.ps1 b/src/functions/public/Forwards/Add-DomeneshopForward.ps1 index 9f1e201..a515173 100644 --- a/src/functions/public/Forwards/Add-DomeneshopForward.ps1 +++ b/src/functions/public/Forwards/Add-DomeneshopForward.ps1 @@ -1,24 +1,61 @@ function Add-DomeneshopForward { <# .SYNOPSIS - Adds an HTTP forward to a Domeneshop domain. + Add an HTTP forward to a Domeneshop domain. + + .DESCRIPTION + Create an HTTP forward from the supplied Domeneshop API request object. + + .EXAMPLE + Add-DomeneshopForward -DomainID 42 -Forward @{ host = 'www'; url = 'https://example.net' } + + 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://api.domeneshop.no/docs/ #> [OutputType([object])] - [CmdletBinding()] + [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 ) - $resolvedContext = Resolve-DomeneshopContext -Context $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/" - Invoke-DomeneshopApiRequest -Method Post -Uri $uri -Context $resolvedContext -Body $Forward + 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/Get-DomeneshopForward.ps1 b/src/functions/public/Forwards/Get-DomeneshopForward.ps1 index c7a994f..aa812d3 100644 --- a/src/functions/public/Forwards/Get-DomeneshopForward.ps1 +++ b/src/functions/public/Forwards/Get-DomeneshopForward.ps1 @@ -5,25 +5,62 @@ function Get-DomeneshopForward { .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://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, - [Parameter(Mandatory, ParameterSetName = 'GetByHost')] + # The host name of a specific HTTP forward. + [Parameter(Mandatory, ParameterSetName = 'Get by host')] + [ValidateNotNullOrEmpty()] [Alias('Host')] [string] $ForwardHost, + # The stored credential context to use instead of the default. [Parameter()] + [ValidateNotNullOrEmpty()] [string] $Context ) - $resolvedContext = Resolve-DomeneshopContext -Context $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 'GetByHost') { + $uri = if ($PSCmdlet.ParameterSetName -eq 'Get by host') { $encodedHost = [uri]::EscapeDataString($ForwardHost) "$apiBaseUri/domains/$DomainID/forwards/$encodedHost" } else { diff --git a/src/functions/public/Forwards/Remove-DomeneshopForward.ps1 b/src/functions/public/Forwards/Remove-DomeneshopForward.ps1 index 7e21f77..5e4ff43 100644 --- a/src/functions/public/Forwards/Remove-DomeneshopForward.ps1 +++ b/src/functions/public/Forwards/Remove-DomeneshopForward.ps1 @@ -1,23 +1,58 @@ function Remove-DomeneshopForward { <# .SYNOPSIS - Removes an HTTP forward from a Domeneshop domain. + Remove an HTTP forward from a Domeneshop domain. + + .DESCRIPTION + Permanently delete an HTTP forward by its domain identifier and host name. + + .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://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 host name of the HTTP forward to remove. [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [Alias('Host')] [string] $ForwardHost, + # The stored credential context to use instead of the default. [Parameter()] + [ValidateNotNullOrEmpty()] [string] $Context ) - $resolvedContext = Resolve-DomeneshopContext -Context $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" diff --git a/src/functions/public/Forwards/Set-DomeneshopForward.ps1 b/src/functions/public/Forwards/Set-DomeneshopForward.ps1 index 3901689..a3216ee 100644 --- a/src/functions/public/Forwards/Set-DomeneshopForward.ps1 +++ b/src/functions/public/Forwards/Set-DomeneshopForward.ps1 @@ -1,29 +1,68 @@ function Set-DomeneshopForward { <# .SYNOPSIS - Updates an HTTP forward on a Domeneshop domain. + Update an HTTP forward on a Domeneshop domain. + + .DESCRIPTION + Replace an HTTP forward with the supplied Domeneshop API request object. + + .EXAMPLE + Set-DomeneshopForward -DomainID 42 -ForwardHost 'www' -Forward @{ host = 'www'; url = 'https://example.org' } + + 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://api.domeneshop.no/docs/ #> [OutputType([object])] - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess)] param( + # The numeric identifier of the domain that owns the forward. [Parameter(Mandatory)] + [ValidateRange(1, [int]::MaxValue)] [int] $DomainID, + # The host name of the HTTP forward to update. [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] [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 ) - $resolvedContext = Resolve-DomeneshopContext -Context $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" - Invoke-DomeneshopApiRequest -Method Put -Uri $uri -Context $resolvedContext -Body $Forward + 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/Invoices/Get-DomeneshopInvoice.ps1 b/src/functions/public/Invoices/Get-DomeneshopInvoice.ps1 index 1a6495c..fccde27 100644 --- a/src/functions/public/Invoices/Get-DomeneshopInvoice.ps1 +++ b/src/functions/public/Invoices/Get-DomeneshopInvoice.ps1 @@ -5,21 +5,56 @@ function Get-DomeneshopInvoice { .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://api.domeneshop.no/docs/ #> [OutputType([object[]])] [CmdletBinding(DefaultParameterSetName = 'List')] param( - [Parameter(Mandatory, ParameterSetName = 'GetByID')] + # 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 ) - $resolvedContext = Resolve-DomeneshopContext -Context $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 'GetByID') { + $uri = if ($PSCmdlet.ParameterSetName -eq 'Get by ID') { "$apiBaseUri/invoices/$InvoiceID" } else { "$apiBaseUri/invoices" diff --git a/tests/DomeneshopContext.Tests.ps1 b/tests/DomeneshopContext.Tests.ps1 index b42e7b2..a56d199 100644 --- a/tests/DomeneshopContext.Tests.ps1 +++ b/tests/DomeneshopContext.Tests.ps1 @@ -40,7 +40,7 @@ Describe 'Domeneshop context and auth flow' { } It 'Get-DomeneshopDomain calls domains endpoint with domain filter' { - Mock Resolve-DomeneshopContext { + Mock Get-DomeneshopContext { [pscustomobject]@{ ID = 'demo' Token = 'token' @@ -77,7 +77,7 @@ Describe 'Domeneshop context and auth flow' { } It 'Get-DomeneshopDomain can get a domain by ID' { - Mock Resolve-DomeneshopContext { + Mock Get-DomeneshopContext { [pscustomobject]@{ ID = 'demo' Token = 'token' @@ -95,7 +95,7 @@ Describe 'Domeneshop context and auth flow' { } It 'Get-DomeneshopDnsRecord builds list query and endpoint' { - Mock Resolve-DomeneshopContext { + Mock Get-DomeneshopContext { [pscustomobject]@{ ID = 'demo' Token = 'token' @@ -114,7 +114,7 @@ Describe 'Domeneshop context and auth flow' { } It 'Get-DomeneshopDnsRecord can get record by ID' { - Mock Resolve-DomeneshopContext { + Mock Get-DomeneshopContext { [pscustomobject]@{ ID = 'demo' Token = 'token' @@ -133,7 +133,7 @@ Describe 'Domeneshop context and auth flow' { } It 'DNS mutating commands call expected endpoints' { - Mock Resolve-DomeneshopContext { + Mock Get-DomeneshopContext { [pscustomobject]@{ ID = 'demo' Token = 'token' @@ -160,7 +160,7 @@ Describe 'Domeneshop context and auth flow' { } It 'Forward commands call expected endpoints' { - Mock Resolve-DomeneshopContext { + Mock Get-DomeneshopContext { [pscustomobject]@{ ID = 'demo' Token = 'token' @@ -195,7 +195,7 @@ Describe 'Domeneshop context and auth flow' { } It 'Get-DomeneshopInvoice supports list and by ID' { - Mock Resolve-DomeneshopContext { + Mock Get-DomeneshopContext { [pscustomobject]@{ ID = 'demo' Token = 'token' @@ -217,7 +217,7 @@ Describe 'Domeneshop context and auth flow' { } It 'Update-DomeneshopDdns builds hostname and myip query' { - Mock Resolve-DomeneshopContext { + Mock Get-DomeneshopContext { [pscustomobject]@{ ID = 'demo' Token = 'token' From 137223a38e3629a01ef448b77827de090c582c9a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 25 Jul 2026 18:09:56 +0200 Subject: [PATCH 2/4] Split tests by public command Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/Add-DomeneshopDnsRecord.Tests.ps1 | 38 +++ tests/Add-DomeneshopForward.Tests.ps1 | 38 +++ tests/Connect-DomeneshopAccount.Tests.ps1 | 49 ++++ tests/Domeneshop.Private.Tests.ps1 | 57 +++++ tests/Domeneshop.TestSetup.ps1 | 19 ++ tests/DomeneshopContext.Tests.ps1 | 236 ------------------ tests/Get-DomeneshopContext.Tests.ps1 | 47 ++++ tests/Get-DomeneshopDnsRecord.Tests.ps1 | 39 +++ tests/Get-DomeneshopDomain.Tests.ps1 | 43 ++++ tests/Get-DomeneshopForward.Tests.ps1 | 39 +++ tests/Get-DomeneshopInvoice.Tests.ps1 | 37 +++ ...t.Tests.ps1 => Get-PSModuleTest.Tests.ps1} | 4 +- tests/Remove-DomeneshopDnsRecord.Tests.ps1 | 36 +++ tests/Remove-DomeneshopForward.Tests.ps1 | 36 +++ tests/Set-DomeneshopDnsRecord.Tests.ps1 | 38 +++ tests/Set-DomeneshopForward.Tests.ps1 | 38 +++ tests/Update-DomeneshopDdns.Tests.ps1 | 36 +++ 17 files changed, 592 insertions(+), 238 deletions(-) create mode 100644 tests/Add-DomeneshopDnsRecord.Tests.ps1 create mode 100644 tests/Add-DomeneshopForward.Tests.ps1 create mode 100644 tests/Connect-DomeneshopAccount.Tests.ps1 create mode 100644 tests/Domeneshop.Private.Tests.ps1 create mode 100644 tests/Domeneshop.TestSetup.ps1 delete mode 100644 tests/DomeneshopContext.Tests.ps1 create mode 100644 tests/Get-DomeneshopContext.Tests.ps1 create mode 100644 tests/Get-DomeneshopDnsRecord.Tests.ps1 create mode 100644 tests/Get-DomeneshopDomain.Tests.ps1 create mode 100644 tests/Get-DomeneshopForward.Tests.ps1 create mode 100644 tests/Get-DomeneshopInvoice.Tests.ps1 rename tests/{PSModuleTest.Tests.ps1 => Get-PSModuleTest.Tests.ps1} (88%) create mode 100644 tests/Remove-DomeneshopDnsRecord.Tests.ps1 create mode 100644 tests/Remove-DomeneshopForward.Tests.ps1 create mode 100644 tests/Set-DomeneshopDnsRecord.Tests.ps1 create mode 100644 tests/Set-DomeneshopForward.Tests.ps1 create mode 100644 tests/Update-DomeneshopDdns.Tests.ps1 diff --git a/tests/Add-DomeneshopDnsRecord.Tests.ps1 b/tests/Add-DomeneshopDnsRecord.Tests.ps1 new file mode 100644 index 0000000..6945f42 --- /dev/null +++ b/tests/Add-DomeneshopDnsRecord.Tests.ps1 @@ -0,0 +1,38 @@ +[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 { + . "$PSScriptRoot\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 + } +} diff --git a/tests/Add-DomeneshopForward.Tests.ps1 b/tests/Add-DomeneshopForward.Tests.ps1 new file mode 100644 index 0000000..8a98827 --- /dev/null +++ b/tests/Add-DomeneshopForward.Tests.ps1 @@ -0,0 +1,38 @@ +[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 { + . "$PSScriptRoot\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 + } +} diff --git a/tests/Connect-DomeneshopAccount.Tests.ps1 b/tests/Connect-DomeneshopAccount.Tests.ps1 new file mode 100644 index 0000000..0caabe9 --- /dev/null +++ b/tests/Connect-DomeneshopAccount.Tests.ps1 @@ -0,0 +1,49 @@ +[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 { + . "$PSScriptRoot\Domeneshop.TestSetup.ps1" + } + + BeforeEach { + Mock Set-Context {} + Mock Get-DomeneshopConfig { [pscustomobject]@{ DefaultContext = $null } } + Mock Set-DomeneshopDefaultContext {} + Mock Get-DomeneshopContext { [pscustomobject]@{ ID = 'demo' } } + } + + 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' + } + } + + 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 'rejects unsupported secret types' { + { Connect-DomeneshopAccount -Token 'token' -Secret 42 -Context 'demo' } | + Should -Throw '*Secret must be a SecureString or String value*' + } +} diff --git a/tests/Domeneshop.Private.Tests.ps1 b/tests/Domeneshop.Private.Tests.ps1 new file mode 100644 index 0000000..07d03e0 --- /dev/null +++ b/tests/Domeneshop.Private.Tests.ps1 @@ -0,0 +1,57 @@ +[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 { + . "$PSScriptRoot\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 '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..c50653e --- /dev/null +++ b/tests/Domeneshop.TestSetup.ps1 @@ -0,0 +1,19 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'The fixed local test credential never leaves the mocked request boundary.' +)] +[CmdletBinding()] +param() + +$functionFiles = Get-ChildItem -Path "$PSScriptRoot\..\src\functions" -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/DomeneshopContext.Tests.ps1 b/tests/DomeneshopContext.Tests.ps1 deleted file mode 100644 index a56d199..0000000 --- a/tests/DomeneshopContext.Tests.ps1 +++ /dev/null @@ -1,236 +0,0 @@ -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSReviewUnusedParameter', '', - Justification = 'Required for Pester tests' -)] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', '', - Justification = 'Required for Pester tests' -)] -[CmdletBinding()] -param() - -Describe 'Domeneshop context and auth flow' { - BeforeAll { - $functionFiles = Get-ChildItem -Path "$PSScriptRoot\..\src\functions" -Filter '*.ps1' -Recurse -File | Sort-Object -Property FullName - foreach ($file in $functionFiles) { - . $file.FullName - } - } - - It 'Connect-DomeneshopAccount stores credentials in Context and can set default' { - Mock Set-Context {} - Mock Get-DomeneshopConfig { [pscustomobject]@{ DefaultContext = $null } } - Mock Set-DomeneshopDefaultContext {} - Mock Get-DomeneshopContext { [pscustomobject]@{ ID = 'demo' } } - - Connect-DomeneshopAccount -Token 'token' -Secret 'secret' -Context 'demo' -PassThru | Should -Not -BeNullOrEmpty - - 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 - } - - It 'Get-DomeneshopContext throws when default context is not configured' { - Mock Get-DomeneshopConfig { [pscustomobject]@{ DefaultContext = '' } } - - { Get-DomeneshopContext } | Should -Throw - } - - It 'Get-DomeneshopDomain calls domains endpoint with domain filter' { - Mock Get-DomeneshopContext { - [pscustomobject]@{ - ID = 'demo' - Token = 'token' - Secret = ConvertTo-SecureString -AsPlainText 'secret' -Force - ApiBaseUri = 'https://api.domeneshop.no/v0' - } - } - Mock Invoke-DomeneshopApiRequest { @() } - - { Get-DomeneshopDomain -Context 'demo' -Domain '.no' } | Should -Not -Throw - - Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Get' -and - $Uri -eq 'https://api.domeneshop.no/v0/domains?domain=.no' -and - $Context.ID -eq 'demo' - } - } - - It 'Invoke-DomeneshopApiRequest uses basic authentication' { - Mock Invoke-RestMethod { [pscustomobject]@{ ok = $true } } - $contextObject = [pscustomobject]@{ - Token = 'token' - Secret = ConvertTo-SecureString -AsPlainText 'secret' -Force - } - - $result = Invoke-DomeneshopApiRequest -Method Get -Uri 'https://api.domeneshop.no/v0/domains' -Context $contextObject - - $result.ok | Should -BeTrue - Should -Invoke Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Get' -and - $Authentication -eq 'Basic' -and - $Credential.UserName -eq 'token' - } - } - - It 'Get-DomeneshopDomain can get a domain by ID' { - Mock Get-DomeneshopContext { - [pscustomobject]@{ - ID = 'demo' - Token = 'token' - Secret = ConvertTo-SecureString -AsPlainText 'secret' -Force - ApiBaseUri = 'https://api.domeneshop.no/v0' - } - } - Mock Invoke-DomeneshopApiRequest { @{} } - - { Get-DomeneshopDomain -Context 'demo' -DomainID 42 } | Should -Not -Throw - Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Get' -and - $Uri -eq 'https://api.domeneshop.no/v0/domains/42' - } - } - - It 'Get-DomeneshopDnsRecord builds list query and endpoint' { - Mock Get-DomeneshopContext { - [pscustomobject]@{ - ID = 'demo' - Token = 'token' - Secret = ConvertTo-SecureString -AsPlainText 'secret' -Force - ApiBaseUri = 'https://api.domeneshop.no/v0' - } - } - Mock Invoke-DomeneshopApiRequest { @() } - - Get-DomeneshopDnsRecord -DomainID 9 -Host 'www' -Type 'A' -Data '127.0.0.1' - - Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Get' -and - $Uri -eq 'https://api.domeneshop.no/v0/domains/9/dns?host=www&type=A&data=127.0.0.1' - } - } - - It 'Get-DomeneshopDnsRecord can get record by ID' { - Mock Get-DomeneshopContext { - [pscustomobject]@{ - ID = 'demo' - Token = 'token' - Secret = ConvertTo-SecureString -AsPlainText 'secret' -Force - ApiBaseUri = 'https://api.domeneshop.no/v0' - } - } - Mock Invoke-DomeneshopApiRequest { @{} } - - Get-DomeneshopDnsRecord -DomainID 9 -RecordID 3 - - Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Get' -and - $Uri -eq 'https://api.domeneshop.no/v0/domains/9/dns/3' - } - } - - It 'DNS mutating commands call expected endpoints' { - Mock Get-DomeneshopContext { - [pscustomobject]@{ - ID = 'demo' - Token = 'token' - Secret = ConvertTo-SecureString -AsPlainText 'secret' -Force - ApiBaseUri = 'https://api.domeneshop.no/v0' - } - } - Mock Invoke-DomeneshopApiRequest { @{} } - - $record = @{ host = 'www'; type = 'A'; data = '127.0.0.1' } - Add-DomeneshopDnsRecord -DomainID 9 -Record $record - Set-DomeneshopDnsRecord -DomainID 9 -RecordID 3 -Record $record - Remove-DomeneshopDnsRecord -DomainID 9 -RecordID 3 -Confirm:$false - - Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Post' -and $Uri -eq 'https://api.domeneshop.no/v0/domains/9/dns' - } - Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Put' -and $Uri -eq 'https://api.domeneshop.no/v0/domains/9/dns/3' - } - Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Delete' -and $Uri -eq 'https://api.domeneshop.no/v0/domains/9/dns/3' - } - } - - It 'Forward commands call expected endpoints' { - Mock Get-DomeneshopContext { - [pscustomobject]@{ - ID = 'demo' - Token = 'token' - Secret = ConvertTo-SecureString -AsPlainText 'secret' -Force - ApiBaseUri = 'https://api.domeneshop.no/v0' - } - } - Mock Invoke-DomeneshopApiRequest { @{} } - - $forward = @{ host = 'www'; url = 'https://example.net' } - Get-DomeneshopForward -DomainID 9 - Get-DomeneshopForward -DomainID 9 -Host 'www' - Add-DomeneshopForward -DomainID 9 -Forward $forward - Set-DomeneshopForward -DomainID 9 -Host 'www' -Forward $forward - Remove-DomeneshopForward -DomainID 9 -Host 'www' -Confirm:$false - - Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Get' -and $Uri -eq 'https://api.domeneshop.no/v0/domains/9/forwards/' - } - Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Get' -and $Uri -eq 'https://api.domeneshop.no/v0/domains/9/forwards/www' - } - Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Post' -and $Uri -eq 'https://api.domeneshop.no/v0/domains/9/forwards/' - } - Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Put' -and $Uri -eq 'https://api.domeneshop.no/v0/domains/9/forwards/www' - } - Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Delete' -and $Uri -eq 'https://api.domeneshop.no/v0/domains/9/forwards/www' - } - } - - It 'Get-DomeneshopInvoice supports list and by ID' { - Mock Get-DomeneshopContext { - [pscustomobject]@{ - ID = 'demo' - Token = 'token' - Secret = ConvertTo-SecureString -AsPlainText 'secret' -Force - ApiBaseUri = 'https://api.domeneshop.no/v0' - } - } - Mock Invoke-DomeneshopApiRequest { @() } - - Get-DomeneshopInvoice - Get-DomeneshopInvoice -InvoiceID 7 - - Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Get' -and $Uri -eq 'https://api.domeneshop.no/v0/invoices' - } - Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Get' -and $Uri -eq 'https://api.domeneshop.no/v0/invoices/7' - } - } - - It 'Update-DomeneshopDdns builds hostname and myip query' { - Mock Get-DomeneshopContext { - [pscustomobject]@{ - ID = 'demo' - Token = 'token' - Secret = ConvertTo-SecureString -AsPlainText 'secret' -Force - ApiBaseUri = 'https://api.domeneshop.no/v0' - } - } - Mock Invoke-DomeneshopApiRequest { @{} } - - Update-DomeneshopDdns -Hostname 'home.example.com' -MyIP '1.2.3.4' - - Should -Invoke Invoke-DomeneshopApiRequest -Times 1 -Exactly -ParameterFilter { - $Method -eq 'Get' -and $Uri -eq 'https://api.domeneshop.no/v0/dyndns/update?hostname=home.example.com&myip=1.2.3.4' - } - } -} diff --git a/tests/Get-DomeneshopContext.Tests.ps1 b/tests/Get-DomeneshopContext.Tests.ps1 new file mode 100644 index 0000000..7aaa1be --- /dev/null +++ b/tests/Get-DomeneshopContext.Tests.ps1 @@ -0,0 +1,47 @@ +[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-DomeneshopContext' { + BeforeAll { + . "$PSScriptRoot\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 '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/Get-DomeneshopDnsRecord.Tests.ps1 b/tests/Get-DomeneshopDnsRecord.Tests.ps1 new file mode 100644 index 0000000..b08d8b7 --- /dev/null +++ b/tests/Get-DomeneshopDnsRecord.Tests.ps1 @@ -0,0 +1,39 @@ +[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-DomeneshopDnsRecord' { + BeforeAll { + . "$PSScriptRoot\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' + } + } +} diff --git a/tests/Get-DomeneshopDomain.Tests.ps1 b/tests/Get-DomeneshopDomain.Tests.ps1 new file mode 100644 index 0000000..ab4cc78 --- /dev/null +++ b/tests/Get-DomeneshopDomain.Tests.ps1 @@ -0,0 +1,43 @@ +[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 { + . "$PSScriptRoot\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 + } +} diff --git a/tests/Get-DomeneshopForward.Tests.ps1 b/tests/Get-DomeneshopForward.Tests.ps1 new file mode 100644 index 0000000..d2603dd --- /dev/null +++ b/tests/Get-DomeneshopForward.Tests.ps1 @@ -0,0 +1,39 @@ +[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-DomeneshopForward' { + BeforeAll { + . "$PSScriptRoot\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' + } + } +} diff --git a/tests/Get-DomeneshopInvoice.Tests.ps1 b/tests/Get-DomeneshopInvoice.Tests.ps1 new file mode 100644 index 0000000..df804ab --- /dev/null +++ b/tests/Get-DomeneshopInvoice.Tests.ps1 @@ -0,0 +1,37 @@ +[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 { + . "$PSScriptRoot\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/PSModuleTest.Tests.ps1 b/tests/Get-PSModuleTest.Tests.ps1 similarity index 88% rename from tests/PSModuleTest.Tests.ps1 rename to tests/Get-PSModuleTest.Tests.ps1 index fb9fdde..46a803b 100644 --- a/tests/PSModuleTest.Tests.ps1 +++ b/tests/Get-PSModuleTest.Tests.ps1 @@ -9,7 +9,7 @@ [CmdletBinding()] param() -Describe 'Module' { +Describe 'Get-PSModuleTest' { BeforeAll { $functionFiles = Get-ChildItem -Path "$PSScriptRoot\..\src\functions" -Filter '*.ps1' -Recurse -File | Sort-Object -Property FullName foreach ($file in $functionFiles) { @@ -17,7 +17,7 @@ Describe 'Module' { } } - It 'Function: Get-PSModuleTest' { + It 'returns a greeting for the supplied name' { Get-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' } } diff --git a/tests/Remove-DomeneshopDnsRecord.Tests.ps1 b/tests/Remove-DomeneshopDnsRecord.Tests.ps1 new file mode 100644 index 0000000..ec3ea2d --- /dev/null +++ b/tests/Remove-DomeneshopDnsRecord.Tests.ps1 @@ -0,0 +1,36 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester mock parameter filters.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester test setup.' +)] +[CmdletBinding()] +param() + +Describe 'Remove-DomeneshopDnsRecord' { + BeforeAll { + . "$PSScriptRoot\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 + } +} diff --git a/tests/Remove-DomeneshopForward.Tests.ps1 b/tests/Remove-DomeneshopForward.Tests.ps1 new file mode 100644 index 0000000..eb0330d --- /dev/null +++ b/tests/Remove-DomeneshopForward.Tests.ps1 @@ -0,0 +1,36 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester mock parameter filters.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester test setup.' +)] +[CmdletBinding()] +param() + +Describe 'Remove-DomeneshopForward' { + BeforeAll { + . "$PSScriptRoot\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 + } +} diff --git a/tests/Set-DomeneshopDnsRecord.Tests.ps1 b/tests/Set-DomeneshopDnsRecord.Tests.ps1 new file mode 100644 index 0000000..2272362 --- /dev/null +++ b/tests/Set-DomeneshopDnsRecord.Tests.ps1 @@ -0,0 +1,38 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester mock parameter filters.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester test setup.' +)] +[CmdletBinding()] +param() + +Describe 'Set-DomeneshopDnsRecord' { + BeforeAll { + . "$PSScriptRoot\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/Set-DomeneshopForward.Tests.ps1 b/tests/Set-DomeneshopForward.Tests.ps1 new file mode 100644 index 0000000..7b7687f --- /dev/null +++ b/tests/Set-DomeneshopForward.Tests.ps1 @@ -0,0 +1,38 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester mock parameter filters.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester test setup.' +)] +[CmdletBinding()] +param() + +Describe 'Set-DomeneshopForward' { + BeforeAll { + . "$PSScriptRoot\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 + } +} diff --git a/tests/Update-DomeneshopDdns.Tests.ps1 b/tests/Update-DomeneshopDdns.Tests.ps1 new file mode 100644 index 0000000..e2beeeb --- /dev/null +++ b/tests/Update-DomeneshopDdns.Tests.ps1 @@ -0,0 +1,36 @@ +[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 { + . "$PSScriptRoot\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 + } +} From 5983628c1371b44cda2927ee152173d607f48ad7 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 25 Jul 2026 18:10:34 +0200 Subject: [PATCH 3/4] Document Domeneshop command groups Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/functions/public/Auth/Auth.md | 8 ++++++++ src/functions/public/Ddns/Ddns.md | 7 +++++++ src/functions/public/Dns/Dns.md | 10 ++++++++++ src/functions/public/Domains/Domains.md | 7 +++++++ src/functions/public/Forwards/Forwards.md | 10 ++++++++++ src/functions/public/Invoices/Invoices.md | 7 +++++++ 6 files changed, 49 insertions(+) create mode 100644 src/functions/public/Auth/Auth.md create mode 100644 src/functions/public/Ddns/Ddns.md create mode 100644 src/functions/public/Dns/Dns.md create mode 100644 src/functions/public/Domains/Domains.md create mode 100644 src/functions/public/Forwards/Forwards.md create mode 100644 src/functions/public/Invoices/Invoices.md 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/Ddns/Ddns.md b/src/functions/public/Ddns/Ddns.md new file mode 100644 index 0000000..edf5515 --- /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 host name with an explicit IP address or the caller's public address. 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/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/Forwards/Forwards.md b/src/functions/public/Forwards/Forwards.md new file mode 100644 index 0000000..87f780b --- /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 host name. +- `Remove-DomeneshopForward`: Removes an HTTP forward from a domain. +- `Set-DomeneshopForward`: Updates an HTTP forward on a domain. 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. From e444b2323b2edd6a06e9a16ea7a931ab5930d1ce Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 25 Jul 2026 18:14:30 +0200 Subject: [PATCH 4/4] Align baseline command help and validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/functions/public/Get-PSModuleTest.ps1 | 33 ++++++++++++++++++----- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/src/functions/public/Get-PSModuleTest.ps1 b/src/functions/public/Get-PSModuleTest.ps1 index 77483d0..e13056a 100644 --- a/src/functions/public/Get-PSModuleTest.ps1 +++ b/src/functions/public/Get-PSModuleTest.ps1 @@ -1,21 +1,40 @@ 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' - "Hello, World!" + Get the greeting "Hello, World!". + + .INPUTS + None + + You can't pipe objects to Get-PSModuleTest. + + .OUTPUTS + System.String + + A greeting containing the supplied name. + + .NOTES + This command is retained as the module's baseline smoke-test command. + + .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()] [string] $Name ) - Write-Output "Hello, $Name!" + + "Hello, $Name!" }