Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

### Changed

- `Add-PatServer -Force` now overwrites an existing entry with the same `-Name` instead of throwing, matching the conventional PowerShell `-Force` semantics established by `New-Item -Force`, `Copy-Item -Force`, and `Register-PSRepository -Force`. Replace is wholesale: fields not supplied on the new call (e.g. `localUri`, `preferLocal`, `default`) are not preserved, and any vault-stored token for the old entry is removed before the new entry is written. Use `Update-PatServerToken` for in-place token rotation that preserves other fields. The duplicate-name error without `-Force` now points to both recovery paths.

## [0.11.0] - 2026-04-27

### Added
Expand Down
44 changes: 37 additions & 7 deletions PlexAutomationToolkit/Public/Add-PatServer.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,15 @@ function Add-PatServer {
Use this when adding a server that is temporarily offline or not yet configured.

.PARAMETER Force
Suppresses all interactive prompts. When specified:
Suppresses interactive prompts and allows overwriting an existing server entry.
When specified:
- If a server with the same Name already exists, replaces it wholesale rather than
throwing. The existing entry's stored token (vault or plaintext) is removed before
the new entry is written. Fields not supplied on the new call are not preserved.
- Automatically accepts HTTPS upgrade if available
- Automatically attempts authentication if server requires it
Use this parameter for non-interactive scripts and automation.
Use this parameter for non-interactive scripts and automation, or to recover from
an expired token by re-running with a fresh -Token value.

.PARAMETER LocalUri
Optional local network URI for the server (e.g., http://192.168.1.100:32400).
Expand Down Expand Up @@ -92,6 +97,14 @@ function Add-PatServer {
Adds a server with explicit local and remote URIs. The module will automatically
use the local URI when reachable, falling back to the remote URI when not.

.EXAMPLE
Add-PatServer -Name "plex" -ServerUri "https://plex.example.com:32400" -Token $newToken -Force

Replaces an existing server entry named "plex" with the supplied configuration.
Without -Force, this would throw because the name is already in use. Use
Update-PatServerToken if you only need to refresh the token while preserving
other fields.

.NOTES
Security: If Microsoft.PowerShell.SecretManagement is installed with a registered vault,
tokens are stored securely. Otherwise, tokens are stored in PLAINTEXT in servers.json.
Expand Down Expand Up @@ -176,9 +189,17 @@ function Add-PatServer {

$configuration = Get-PatServerConfiguration -ErrorAction Stop

# Check for duplicate name
if ($configuration.servers | Where-Object { $_.name -eq $Name }) {
throw "A server with name '$Name' already exists"
# Check for duplicate name. Without -Force, throw so the user must opt in to clobbering.
# With -Force, defer the actual removal of the existing entry (and any vault token
# cleanup) until the ShouldProcess block below, so -WhatIf and a declined -Confirm
# do not destroy state.
$existingServer = $configuration.servers | Where-Object { $_.name -eq $Name }
if ($existingServer -and -not $Force) {
throw "A server with name '$Name' already exists. Use -Force to overwrite, or Update-PatServerToken to refresh the token in place."
}
$replaceExisting = [bool]$existingServer
if ($replaceExisting) {
Write-Verbose "Will replace existing server '$Name' (-Force specified)"
}

# If marking as default, unset other defaults
Expand Down Expand Up @@ -301,9 +322,18 @@ function Add-PatServer {
Write-Verbose "Skipping server validation as requested"
}

$configuration.servers += $newServer

if ($PSCmdlet.ShouldProcess($Name, 'Add server to configuration')) {
if ($replaceExisting) {
# Strip the old entry from the working copy, then clean up its vault token
# if the new entry isn't vault-stored. When the new entry has tokenInVault,
# Set-PatServerToken (above) has already overwritten the vault entry with
# the new token, so removing it here would clobber that fresh value.
$configuration.servers = @($configuration.servers | Where-Object { $_.name -ne $Name })
if ($newServer.tokenInVault -ne $true) {
Remove-PatServerToken -ServerName $Name
}
}
$configuration.servers += $newServer
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Set-PatServerConfiguration -Configuration $configuration -ErrorAction Stop
Write-Verbose "Added server '$Name' to configuration"

Expand Down
92 changes: 91 additions & 1 deletion tests/Unit/Public/Add-PatServer.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ Describe 'Add-PatServer' {
Token = $Token
}
}

# Mock vault token cleanup; -Force overwrite path invokes this before re-adding.
Mock -CommandName Remove-PatServerToken -ModuleName PlexAutomationToolkit -MockWith { }
}

Context 'Adding a basic server' {
Expand Down Expand Up @@ -112,18 +115,105 @@ Describe 'Add-PatServer' {
}

Context 'Duplicate handling' {
It 'Should throw on duplicate server name' {
It 'Should throw on duplicate server name without -Force' {
Add-PatServer -Name 'Duplicate' -ServerUri 'http://dup1:32400'

{ Add-PatServer -Name 'Duplicate' -ServerUri 'http://dup2:32400' } | Should -Throw "*already exists*"
}

It 'Should suggest -Force and Update-PatServerToken in the duplicate error' {
Add-PatServer -Name 'Duplicate' -ServerUri 'http://dup1:32400'

{ Add-PatServer -Name 'Duplicate' -ServerUri 'http://dup2:32400' } |
Should -Throw "*-Force*Update-PatServerToken*"
}

It 'Should allow same URI with different names' {
Add-PatServer -Name 'Server1' -ServerUri 'http://same:32400'
Add-PatServer -Name 'Server2' -ServerUri 'http://same:32400'

$script:mockConfig.servers.Count | Should -Be 2
}

It 'Should overwrite existing entry when -Force is specified' {
Add-PatServer -Name 'plex' -ServerUri 'http://old:32400' -Token 'OLD-TOKEN'
Add-PatServer -Name 'plex' -ServerUri 'http://new:32400' -Token 'NEW-TOKEN' -Force -Confirm:$false

$script:mockConfig.servers.Count | Should -Be 1
$script:mockConfig.servers[0].uri | Should -Be 'http://new:32400'
$script:mockConfig.servers[0].token | Should -Be 'NEW-TOKEN'
}

It 'Should remove vault token entry when -Force overwrites with a non-vault-stored new entry' {
# Default mock returns Plaintext, so the new entry has no tokenInVault. Any vault
# entry from the prior add would be orphaned without the cleanup call.
Add-PatServer -Name 'plex' -ServerUri 'http://old:32400' -Token 'OLD-TOKEN'
Add-PatServer -Name 'plex' -ServerUri 'http://new:32400' -Token 'NEW-TOKEN' -Force -Confirm:$false

Should -Invoke Remove-PatServerToken -ModuleName PlexAutomationToolkit -Times 1 -ParameterFilter {
$ServerName -eq 'plex'
}
}

It 'Should not call Remove-PatServerToken when -Force overwrites with a vault-stored new token' {
# When the new -Token is stored in the vault, Set-PatServerToken has already
# overwritten the vault entry; calling Remove-PatServerToken would clobber it.
Mock -CommandName Set-PatServerToken -ModuleName PlexAutomationToolkit -MockWith {
param($ServerName, $Token)
return [PSCustomObject]@{
StorageType = 'Vault'
Token = $null
}
}

Add-PatServer -Name 'plex' -ServerUri 'http://old:32400' -Token 'OLD-TOKEN'
Add-PatServer -Name 'plex' -ServerUri 'http://new:32400' -Token 'NEW-TOKEN' -Force -Confirm:$false

Should -Invoke Remove-PatServerToken -ModuleName PlexAutomationToolkit -Times 0
}

It 'Should not invoke Remove-PatServerToken or persist config when -Force overwrite is run with -WhatIf' {
Add-PatServer -Name 'plex' -ServerUri 'http://old:32400' -Token 'OLD-TOKEN'
# First add wrote the config; from here, -WhatIf must not perform any further writes.
Should -Invoke Set-PatServerConfiguration -ModuleName PlexAutomationToolkit -Times 1

Add-PatServer -Name 'plex' -ServerUri 'http://new:32400' -Token 'NEW-TOKEN' -Force -WhatIf

Should -Invoke Remove-PatServerToken -ModuleName PlexAutomationToolkit -Times 0
Should -Invoke Set-PatServerConfiguration -ModuleName PlexAutomationToolkit -Times 1

# In-memory configuration is unchanged from the first add.
$script:mockConfig.servers.Count | Should -Be 1
$script:mockConfig.servers[0].name | Should -Be 'plex'
$script:mockConfig.servers[0].uri | Should -Be 'http://old:32400'
}

It 'Should replace fields wholesale when -Force overwrites (no merge)' {
Add-PatServer -Name 'plex' -ServerUri 'http://old:32400' -LocalUri 'http://192.168.1.50:32400' -PreferLocal -SkipValidation
Add-PatServer -Name 'plex' -ServerUri 'http://new:32400' -Force -SkipValidation -Confirm:$false

$script:mockConfig.servers.Count | Should -Be 1
$script:mockConfig.servers[0].uri | Should -Be 'http://new:32400'
# Replace semantics: localUri/preferLocal from the old entry are not preserved.
$script:mockConfig.servers[0].PSObject.Properties['localUri'] | Should -BeNullOrEmpty
$script:mockConfig.servers[0].PSObject.Properties['preferLocal'] | Should -BeNullOrEmpty
}

It 'Should preserve other servers when -Force overwrites a single entry' {
Add-PatServer -Name 'keep' -ServerUri 'http://keep:32400'
Add-PatServer -Name 'plex' -ServerUri 'http://old:32400'
Add-PatServer -Name 'plex' -ServerUri 'http://new:32400' -Force -Confirm:$false

$script:mockConfig.servers.Count | Should -Be 2
($script:mockConfig.servers | Where-Object { $_.name -eq 'keep' }).uri | Should -Be 'http://keep:32400'
($script:mockConfig.servers | Where-Object { $_.name -eq 'plex' }).uri | Should -Be 'http://new:32400'
}

It 'Should not call Remove-PatServerToken when -Force adds a brand new entry' {
Add-PatServer -Name 'fresh' -ServerUri 'http://fresh:32400' -Force -Confirm:$false

Should -Invoke Remove-PatServerToken -ModuleName PlexAutomationToolkit -Times 0
}
Comment thread
tablackburn marked this conversation as resolved.
}

Context 'PassThru parameter' {
Expand Down